diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 69f6c51..9cdf1fa 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -11,12 +11,29 @@ jobs: go-version: ['stable', 'oldstable'] steps: - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 with: go-version: ${{ matrix.go-version }} - run: make build + # Need to run tests with a temp dir on same file system for os.Rename to succeed. - run: 'mkdir -p tmp && TMPDIR=$PWD/tmp make test' + - uses: actions/upload-artifact@v3 with: path: cover.html + + # Rebuild webmail frontend code, should be the same as committed. + - uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - run: npm ci + - run: 'touch webmail/*.ts && make frontend' + + # Format code, we check below if nothing changed. + - run: 'make fmt' + + # Enforce the steps above didn't make any changes. + - run: git diff --exit-code diff --git a/.gitignore b/.gitignore index 8c0cf05..8abe8aa 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /testdata/smtpserverfuzz/data/ /testdata/store/data/ /testdata/train/ +/testdata/webmail/data/ /testdata/upgradetest.mbox.gz /testdata/integration/example-integration.zone /testdata/integration/tmp-pebble-ca.pem @@ -30,5 +31,3 @@ /cover.html /.go/ /node_modules/ -/package.json -/package-lock.json diff --git a/Makefile b/Makefile index 752b522..2b4513c 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,11 @@ build: CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go vet -tags integration ./gendoc.sh - (cd http && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Admin) >http/adminapi.json - (cd http && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Account) >http/accountapi.json - # build again, files above are embedded + (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 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 CGO_ENABLED=0 go build test: @@ -73,11 +75,32 @@ fmt: gofmt -w -s *.go */*.go jswatch: - inotifywait -m -e close_write http/admin.html http/account.html | xargs -n2 sh -c 'echo changed; ./checkhtmljs http/admin.html http/account.html' + bash -c 'while true; do inotifywait -q -e close_write webadmin/*.html webaccount/*.html webmail/*.ts; make frontend; done' jsinstall: -mkdir -p node_modules/.bin - npm install jshint@2.13.2 + npm ci + +jsinstall0: + -mkdir -p node_modules/.bin + npm install --save-dev --save-exact jshint@2.13.6 typescript@5.1.6 + +webmail/webmail.js: webmail/api.ts webmail/lib.ts webmail/webmail.ts + ./tsc.sh $@ $^ + +webmail/msg.js: webmail/api.ts webmail/lib.ts webmail/msg.ts + ./tsc.sh $@ $^ + +webmail/text.js: 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 + +webaccount/account.htmlx: + ./node_modules/.bin/jshint --extract always webaccount/account.html | ./fixjshintlines.sh + +frontend: webadmin/admin.htmlx webaccount/account.htmlx webmail/webmail.js webmail/msg.js webmail/text.js docker: docker build -t mox:dev . diff --git a/README.md b/README.md index 0ee715b..10d848f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ See Quickstart below to get started. accounts/domains, and modifying the configuration file. - Autodiscovery (with SRV records, Microsoft-style and Thunderbird-style) for easy account setup (though not many clients support it). +- Webmail for reading/sending email from the browser. - Webserver with serving static files and forwarding requests (reverse proxy), so port 443 can also be used to serve websites. - Prometheus metrics and structured logging for operational insight. @@ -108,16 +109,19 @@ The code is heavily cross-referenced with the RFCs for readability/maintainabili ## Roadmap -- Webmail +- Improve message parsing, more lenient for imported messages +- Ruleset config option for accepting incoming forwarded messages +- Rewrite account and admin javascript to typescript +- Prepare data storage for JMAP - IMAP THREAD extension - DANE and DNSSEC - Sending DMARC and TLS reports (currently only receiving) +- Accepting/processing/monitoring DMARC reports for external domains +- Calendaring - OAUTH2 support, for single sign on - Add special IMAP mailbox ("Queue?") that contains queued but not-yet-delivered messages - Sieve for filtering (for now see Rulesets in the account config) -- Accepting/processing/monitoring DMARC reports for external domains -- Calendaring - Privilege separation, isolating parts of the application to more restricted sandbox (e.g. new unauthenticated connections) - Using mox as backup MX diff --git a/checkhtmljs b/checkhtmljs deleted file mode 100755 index 0e26d24..0000000 --- a/checkhtmljs +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec ./node_modules/.bin/jshint --extract always $@ | fixjshintlines diff --git a/config/config.go b/config/config.go index 120bb3e..f76bd5a 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,10 @@ import ( // todo: better default values, so less has to be specified in the config file. +// DefaultMaxMsgSize is the maximum message size for incoming and outgoing +// messages, in bytes. Can be overridden per listener. +const DefaultMaxMsgSize = 100 * 1024 * 1024 + // Port returns port if non-zero, and fallback otherwise. func Port(port, fallback int) int { if port == 0 { @@ -97,7 +101,7 @@ type Listener struct { HostnameDomain dns.Domain `sconf:"-" json:"-"` // Set when parsing config. TLS *TLS `sconf:"optional" sconf-doc:"For SMTP/IMAP STARTTLS, direct TLS and HTTPS connections."` - SMTPMaxMessageSize int64 `sconf:"optional" sconf-doc:"Maximum size in bytes accepted incoming and outgoing messages. Default is 100MB."` + SMTPMaxMessageSize int64 `sconf:"optional" sconf-doc:"Maximum size in bytes for incoming and outgoing messages. Default is 100MB."` SMTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 25."` @@ -147,6 +151,16 @@ type Listener struct { Port int `sconf:"optional" sconf-doc:"Default 443."` Path string `sconf:"optional" sconf-doc:"Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other resources. Default is /admin/."` } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config. Preferably only enable on non-public IPs."` + WebmailHTTP struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 80."` + Path string `sconf:"optional" sconf-doc:"Path to serve account requests on. Useful if domain serves other resources. Default is /webmail/."` + } `sconf:"optional" sconf-doc:"Webmail client, for reading email."` + WebmailHTTPS struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 443."` + Path string `sconf:"optional" sconf-doc:"Path to serve account requests on. Useful if domain serves other resources. Default is /webmail/."` + } `sconf:"optional" sconf-doc:"Webmail client, for reading email."` MetricsHTTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 8010."` @@ -295,6 +309,7 @@ type Route struct { type Account struct { Domain string `sconf-doc:"Default domain for account. Deprecated behaviour: If a destination is not a full address but only a localpart, this domain is added to form a full address."` Description string `sconf:"optional" sconf-doc:"Free form description, e.g. full name or alternative contact info."` + FullName string `sconf:"optional" sconf-doc:"Full name, to use in message From header when composing messages in webmail. Can be overridden per destination."` Destinations map[string]Destination `sconf-doc:"Destinations, keys are email addresses (with IDNA domains). If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."` SubjectPass struct { Period time.Duration `sconf-doc:"How long unique values are accepted after generating, e.g. 12h."` // todo: have a reasonable default for this? @@ -326,6 +341,7 @@ type JunkFilter struct { type Destination struct { Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."` Rulesets []Ruleset `sconf:"optional" sconf-doc:"Delivery rules based on message and SMTP transaction. You may want to match each mailing list by SMTP MailFrom address, VerifiedDomain and/or List-ID header (typically if the list address is listname@example.org), delivering them to their own mailbox."` + FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."` DMARCReports bool `sconf:"-" json:"-"` TLSReports bool `sconf:"-" json:"-"` diff --git a/config/doc.go b/config/doc.go index 2b95083..9111192 100644 --- a/config/doc.go +++ b/config/doc.go @@ -141,7 +141,7 @@ describe-static" and "mox config describe-domains": # Minimum TLS version. Default: TLSv1.2. (optional) MinVersion: - # Maximum size in bytes accepted incoming and outgoing messages. Default is 100MB. + # Maximum size in bytes for incoming and outgoing messages. Default is 100MB. # (optional) SMTPMaxMessageSize: 0 @@ -265,6 +265,28 @@ describe-static" and "mox config describe-domains": # resources. Default is /admin/. (optional) Path: + # Webmail client, for reading email. (optional) + WebmailHTTP: + Enabled: false + + # Default 80. (optional) + Port: 0 + + # Path to serve account requests on. Useful if domain serves other resources. + # Default is /webmail/. (optional) + Path: + + # Webmail client, for reading email. (optional) + WebmailHTTPS: + Enabled: false + + # Default 443. (optional) + Port: 0 + + # Path to serve account requests on. Useful if domain serves other resources. + # Default is /webmail/. (optional) + Path: + # Serve prometheus metrics, for monitoring. You should not enable this on a public # IP. (optional) MetricsHTTP: @@ -625,6 +647,10 @@ describe-static" and "mox config describe-domains": # Free form description, e.g. full name or alternative contact info. (optional) Description: + # Full name, to use in message From header when composing messages in webmail. Can + # be overridden per destination. (optional) + FullName: + # Destinations, keys are email addresses (with IDNA domains). If the address is of # the form '@domain', i.e. with localpart missing, it serves as a catchall for the # domain, matching all messages that are not explicitly configured. Deprecated @@ -674,6 +700,10 @@ describe-static" and "mox config describe-domains": # Mailbox to deliver to if this ruleset matches. Mailbox: + # Full name to use in message From header when composing messages coming from this + # address with webmail. (optional) + FullName: + # If configured, messages classified as weakly spam are rejected with instructions # to retry delivery, but this time with a signed token added to the subject. # During the next delivery attempt, the signed token will bypass the spam filter. diff --git a/ctl.go b/ctl.go index 16bfa69..6d9f20d 100644 --- a/ctl.go +++ b/ctl.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "io" "log" @@ -672,9 +673,132 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { jf = nil ctl.xcheck(err, "closing junk filter") }) - ctl.xwriteok() + case "recalculatemailboxcounts": + /* protocol: + > "recalculatemailboxcounts" + > account + < "ok" or error + < stream + */ + account := ctl.xread() + acc, err := store.OpenAccount(account) + ctl.xcheck(err, "open account") + defer func() { + if acc != nil { + err := acc.Close() + log.Check(err, "closing account after recalculating mailbox counts") + } + }() + ctl.xwriteok() + + w := ctl.writer() + + acc.WithWLock(func() { + var changes []store.Change + err = acc.DB.Write(ctx, func(tx *bstore.Tx) error { + return bstore.QueryTx[store.Mailbox](tx).ForEach(func(mb store.Mailbox) error { + mc, err := mb.CalculateCounts(tx) + if err != nil { + return fmt.Errorf("calculating counts for mailbox %q: %w", mb.Name, err) + } + + if !mb.HaveCounts || mc != mb.MailboxCounts { + _, err := fmt.Fprintf(w, "for %s setting new counts %s (was %s)\n", mb.Name, mc, mb.MailboxCounts) + ctl.xcheck(err, "write") + mb.HaveCounts = true + mb.MailboxCounts = mc + if err := tx.Update(&mb); err != nil { + return fmt.Errorf("storing new counts for %q: %v", mb.Name, err) + } + changes = append(changes, mb.ChangeCounts()) + } + return nil + }) + }) + ctl.xcheck(err, "write transaction for mailbox counts") + + store.BroadcastChanges(acc, changes) + }) + w.xclose() + + case "reparse": + /* protocol: + > "reparse" + > account or empty + < "ok" or error + < stream + */ + + accountOpt := ctl.xread() + ctl.xwriteok() + w := ctl.writer() + + xreparseAccount := func(accName string) { + acc, err := store.OpenAccount(accName) + ctl.xcheck(err, "open account") + defer func() { + err := acc.Close() + log.Check(err, "closing account after reparsing messages") + }() + + total := 0 + var lastID int64 + for { + var n int + // Batch in transactions of 100 messages, so we don't block the account too long. + err := acc.DB.Write(ctx, func(tx *bstore.Tx) error { + q := bstore.QueryTx[store.Message](tx) + q.FilterEqual("Expunged", false) + q.FilterGreater("ID", lastID) + q.Limit(100) + q.SortAsc("ID") + return q.ForEach(func(m store.Message) error { + lastID = m.ID + mr := acc.MessageReader(m) + p, err := message.EnsurePart(mr, m.Size) + if err != nil { + _, err := fmt.Fprintf(w, "parsing message %d: %v (continuing)\n", m.ID, err) + ctl.xcheck(err, "write") + } + m.ParsedBuf, err = json.Marshal(p) + if err != nil { + return fmt.Errorf("marshal parsed message: %v", err) + } + total++ + n++ + if err := tx.Update(&m); err != nil { + return fmt.Errorf("update message: %v", err) + } + return nil + }) + + }) + ctl.xcheck(err, "update messages with parsed mime structure") + if n < 100 { + break + } + } + _, err = fmt.Fprintf(w, "%d messages reparsed for account %s\n", total, accName) + ctl.xcheck(err, "write") + } + + if accountOpt != "" { + xreparseAccount(accountOpt) + } else { + for i, accName := range mox.Conf.Accounts() { + var line string + if i > 0 { + line = "\n" + } + _, err := fmt.Fprintf(w, "%sreparsing account %s\n", line, accName) + ctl.xcheck(err, "write") + xreparseAccount(accName) + } + } + w.xclose() + case "backup": backupctl(ctx, ctl) diff --git a/ctl_test.go b/ctl_test.go index a9efe96..99509d8 100644 --- a/ctl_test.go +++ b/ctl_test.go @@ -157,6 +157,17 @@ func TestCtl(t *testing.T) { ctlcmdImport(ctl, false, "mjl", "inbox", "testdata/ctl/data/tmp/export/maildir/Inbox") }) + testctl(func(ctl *ctl) { + ctlcmdRecalculateMailboxCounts(ctl, "mjl") + }) + + testctl(func(ctl *ctl) { + ctlcmdReparse(ctl, "mjl") + }) + testctl(func(ctl *ctl) { + ctlcmdReparse(ctl, "") + }) + // "backup", backup account. err = dmarcdb.Init() tcheck(t, err, "dmarcdb init") diff --git a/docker-compose-integration.yml b/docker-compose-integration.yml index 91e6e1c..5063a50 100644 --- a/docker-compose-integration.yml +++ b/docker-compose-integration.yml @@ -87,7 +87,7 @@ services: hostname: localserve.mox1.example domainname: mox1.example image: mox_integration_moxmail - command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; mox localserve -ip 172.28.1.60"] + command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; mox -checkconsistency localserve -ip 172.28.1.60"] volumes: - ./.go:/.go - ./testdata/integration/resolv.conf:/etc/resolv.conf diff --git a/fixjshintlines.sh b/fixjshintlines.sh new file mode 100755 index 0000000..3cad652 --- /dev/null +++ b/fixjshintlines.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# change output to regular filename:linenumber format for easier opening. +arg=$(echo $1 | sed 's,/,\\/,') +exec sed "s/^\([^:]*\): line \([0-9][0-9]*\), \(.*\)\$/${arg}\1:\2: \3/" diff --git a/gentestdata.go b/gentestdata.go index 48647fa..1be5f20 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -54,6 +54,7 @@ func cmdGentestdata(c *cmd) { return f } + log := mlog.New("gentestdata") ctxbg := context.Background() mox.Shutdown = ctxbg mox.Context = ctxbg @@ -233,7 +234,7 @@ Accounts: const qmsg = "From: \r\nTo: \r\nSubject: test\r\n\r\nthe message...\r\n" _, err = fmt.Fprint(mf, qmsg) xcheckf(err, "writing message") - _, err = queue.Add(ctxbg, mlog.New("gentestdata"), "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "", prefix, mf, nil, true) + _, err = queue.Add(ctxbg, log, "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "", prefix, mf, nil, true) xcheckf(err, "enqueue message") // Create three accounts. @@ -280,10 +281,17 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf, msg) xcheckf(err, "writing deliver message to file") - err = accTest1.DeliverMessage(mlog.New("gentestdata"), tx, &m, mf, true, false, false, true) + err = accTest1.DeliverMessage(log, tx, &m, mf, true, false, false, true) xcheckf(err, "add message to account test1") err = mf.Close() xcheckf(err, "closing file") + + err = tx.Get(&inbox) + xcheckf(err, "get inbox") + inbox.Add(m.MailboxCounts()) + err = tx.Update(&inbox) + xcheckf(err, "update inbox") + return nil }) xcheckf(err, "write transaction with new message") @@ -327,11 +335,17 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf0, msg0) xcheckf(err, "writing deliver message to file") - err = accTest2.DeliverMessage(mlog.New("gentestdata"), tx, &m0, mf0, true, false, false, false) + err = accTest2.DeliverMessage(log, tx, &m0, mf0, true, false, false, false) xcheckf(err, "add message to account test2") err = mf0.Close() xcheckf(err, "closing file") + err = tx.Get(&inbox) + xcheckf(err, "get inbox") + inbox.Add(m0.MailboxCounts()) + err = tx.Update(&inbox) + xcheckf(err, "update inbox") + sent, err := bstore.QueryTx[store.Mailbox](tx).FilterNonzero(store.Mailbox{Name: "Sent"}).Get() xcheckf(err, "looking up inbox") const prefix1 = "Extra: test\r\n" @@ -348,11 +362,17 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf1, msg1) xcheckf(err, "writing deliver message to file") - err = accTest2.DeliverMessage(mlog.New("gentestdata"), tx, &m1, mf1, true, true, false, false) + err = accTest2.DeliverMessage(log, tx, &m1, mf1, true, true, false, false) xcheckf(err, "add message to account test2") err = mf1.Close() xcheckf(err, "closing file") + err = tx.Get(&sent) + xcheckf(err, "get sent") + sent.Add(m1.MailboxCounts()) + err = tx.Update(&sent) + xcheckf(err, "update sent") + return nil }) xcheckf(err, "write transaction with new message") diff --git a/go.mod b/go.mod index 6a8ed33..7efa4da 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,17 @@ module github.com/mjl-/mox go 1.18 require ( - github.com/mjl-/bstore v0.0.1 + github.com/mjl-/bstore v0.0.2 github.com/mjl-/sconf v0.0.4 - github.com/mjl-/sherpa v0.6.5 - github.com/mjl-/sherpadoc v0.0.10 + github.com/mjl-/sherpa v0.6.6 + github.com/mjl-/sherpadoc v0.0.12 github.com/mjl-/sherpaprom v0.0.2 + github.com/mjl-/sherpats v0.0.4 github.com/prometheus/client_golang v1.14.0 go.etcd.io/bbolt v1.3.7 golang.org/x/crypto v0.11.0 golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 - golang.org/x/net v0.12.0 + golang.org/x/net v0.13.0 golang.org/x/text v0.11.0 ) diff --git a/go.sum b/go.sum index 0ac1329..37c18fc 100644 --- a/go.sum +++ b/go.sum @@ -145,17 +145,19 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mjl-/bstore v0.0.1 h1:OzQfYgpMCvNjNIj9FFJ3HidYzG6eSlLSYzCTzw9sptY= -github.com/mjl-/bstore v0.0.1/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0= +github.com/mjl-/bstore v0.0.2 h1:4fdpIOY/+Dv1dBHyzdqa4PD90p8Mz86FeyRpI4qcehw= +github.com/mjl-/bstore v0.0.2/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0= github.com/mjl-/sconf v0.0.4 h1:uyfn4vv5qOULSgiwQsPbbgkiONKnMFMsSOhsHfAiYwI= github.com/mjl-/sconf v0.0.4/go.mod h1:ezf7YOn7gtClo8y71SqgZKaEkyMQ5Te7vkv4PmTTfwM= -github.com/mjl-/sherpa v0.6.5 h1:d90uG/j8fw+2M+ohCTAcVwTSUURGm8ktYDScJO1nKog= -github.com/mjl-/sherpa v0.6.5/go.mod h1:dSpAOdgpwdqQZ72O4n3EHo/tR68eKyan8tYYraUMPNc= +github.com/mjl-/sherpa v0.6.6 h1:4Xc4/s12W2I/C1genIL8l4ZCLMsTo8498cPSjQcIHGc= +github.com/mjl-/sherpa v0.6.6/go.mod h1:dSpAOdgpwdqQZ72O4n3EHo/tR68eKyan8tYYraUMPNc= github.com/mjl-/sherpadoc v0.0.0-20190505200843-c0a7f43f5f1d/go.mod h1:5khTKxoKKNXcB8bkVUO6GlzC7PFtMmkHq578lPbmnok= -github.com/mjl-/sherpadoc v0.0.10 h1:tvRVd37IIGg70ZmNkNKNnjDSPtKI5/DdEIukMkWtZYE= -github.com/mjl-/sherpadoc v0.0.10/go.mod h1:vh5zcsk3j/Tvm725EY+unTZb3EZcZcpiEQzrODSa6+I= +github.com/mjl-/sherpadoc v0.0.12 h1:6hVe2Z0DnwjC0bfuOwfz8ov7JTCUU49cEaj7h22NiPk= +github.com/mjl-/sherpadoc v0.0.12/go.mod h1:vh5zcsk3j/Tvm725EY+unTZb3EZcZcpiEQzrODSa6+I= github.com/mjl-/sherpaprom v0.0.2 h1:1dlbkScsNafM5jURI44uiWrZMSwfZtcOFEEq7vx2C1Y= github.com/mjl-/sherpaprom v0.0.2/go.mod h1:cl5nMNOvqhzMiQJ2FzccQ9ReivjHXe53JhOVkPfSvw4= +github.com/mjl-/sherpats v0.0.4 h1:rZkJO4YV4MfuCi3E4ifzbhpY6VgZgsQoOcL04ABEib4= +github.com/mjl-/sherpats v0.0.4/go.mod h1:MoNZJtLmu8oCZ4Ocv5vZksENN4pp6/SJMlg9uTII4KA= github.com/mjl-/xfmt v0.0.0-20190521151243-39d9c00752ce h1:oyFmIHo3GLWZzb0odAzN9QUy0MTW6P8JaNRnNVGCBCk= github.com/mjl-/xfmt v0.0.0-20190521151243-39d9c00752ce/go.mod h1:DIEOLmETMQHHr4OgwPG7iC37rDiN9MaZIZxNm5hBtL8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -292,8 +294,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/http/web.go b/http/web.go index feee6f8..686576f 100644 --- a/http/web.go +++ b/http/web.go @@ -28,6 +28,9 @@ import ( "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/ratelimit" + "github.com/mjl-/mox/webaccount" + "github.com/mjl-/mox/webadmin" + "github.com/mjl-/mox/webmail" ) var xlog = mlog.New("http") @@ -85,8 +88,13 @@ type loggingWriter struct { StatusCode int Size int64 // Of data served, for non-websocket responses. Err error - WebsocketResponse bool // If this was a successful websocket connection with backend. - SizeFromClient, SizeToClient int64 // Websocket data. + WebsocketResponse bool // If this was a successful websocket connection with backend. + SizeFromClient, SizeToClient int64 // Websocket data. + Fields []mlog.Pair // Additional fields to log. +} + +func (w *loggingWriter) AddField(p mlog.Pair) { + w.Fields = append(w.Fields, p) } func (w *loggingWriter) Flush() { @@ -208,6 +216,7 @@ func (w *loggingWriter) Done() { mlog.Field("size", w.Size), ) } + fields = append(fields, w.Fields...) xlog.WithContext(w.R.Context()).Debugx("http request", err, fields...) } @@ -388,7 +397,7 @@ func Listen() { path = l.AccountHTTP.Path } srv := ensureServe(false, port, "account-http at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(accountHandle))) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handle))) srv.Handle("account", nil, path, handler) redirectToTrailingSlash(srv, "account", path) } @@ -399,7 +408,7 @@ func Listen() { path = l.AccountHTTPS.Path } srv := ensureServe(true, port, "account-https at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(accountHandle))) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handle))) srv.Handle("account", nil, path, handler) redirectToTrailingSlash(srv, "account", path) } @@ -411,7 +420,7 @@ func Listen() { path = l.AdminHTTP.Path } srv := ensureServe(false, port, "admin-http at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(adminHandle))) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handle))) srv.Handle("admin", nil, path, handler) redirectToTrailingSlash(srv, "admin", path) } @@ -422,10 +431,36 @@ func Listen() { path = l.AdminHTTPS.Path } srv := ensureServe(true, port, "admin-https at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(adminHandle))) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handle))) srv.Handle("admin", nil, path, handler) redirectToTrailingSlash(srv, "admin", path) } + + maxMsgSize := l.SMTPMaxMessageSize + if maxMsgSize == 0 { + maxMsgSize = config.DefaultMaxMsgSize + } + if l.WebmailHTTP.Enabled { + port := config.Port(l.WebmailHTTP.Port, 80) + path := "/webmail/" + if l.WebmailHTTP.Path != "" { + path = l.WebmailHTTP.Path + } + srv := ensureServe(false, port, "webmail-http at "+path) + srv.Handle("webmail", nil, path, http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize)))) + redirectToTrailingSlash(srv, "webmail", path) + } + if l.WebmailHTTPS.Enabled { + port := config.Port(l.WebmailHTTPS.Port, 443) + path := "/webmail/" + if l.WebmailHTTPS.Path != "" { + path = l.WebmailHTTPS.Path + } + srv := ensureServe(true, port, "webmail-https at "+path) + srv.Handle("webmail", nil, path, http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize)))) + redirectToTrailingSlash(srv, "webmail", path) + } + if l.MetricsHTTP.Enabled { port := config.Port(l.MetricsHTTP.Port, 8010) srv := ensureServe(false, port, "metrics-http") @@ -583,8 +618,8 @@ func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []st // Serve starts serving on the initialized listeners. func Serve() { - go manageAuthCache() - go importManage() + go webadmin.ManageAuthCache() + go webaccount.ImportManage() for _, serve := range servers { go serve() diff --git a/http/webserver_test.go b/http/webserver_test.go index a2ad87a..ba7c559 100644 --- a/http/webserver_test.go +++ b/http/webserver_test.go @@ -18,6 +18,13 @@ import ( "github.com/mjl-/mox/mox-" ) +func tcheck(t *testing.T, err error, msg string) { + t.Helper() + if err != nil { + t.Fatalf("%s: %s", msg, err) + } +} + func TestWebserver(t *testing.T) { os.RemoveAll("../testdata/webserver/data") mox.ConfigStaticPath = "../testdata/webserver/mox.conf" diff --git a/imapserver/append_test.go b/imapserver/append_test.go index e9c51e9..d74b408 100644 --- a/imapserver/append_test.go +++ b/imapserver/append_test.go @@ -50,7 +50,7 @@ func TestAppend(t *testing.T) { tc.transactf("ok", "noop") uid1 := imapclient.FetchUID(1) - flags := imapclient.FetchFlags{`\Seen`, "label1", "$label2"} + flags := imapclient.FetchFlags{`\Seen`, "$label2", "label1"} tc.xuntagged(imapclient.UntaggedExists(1), imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flags}}) tc3.transactf("ok", "noop") tc3.xuntagged() // Inbox is not selected, nothing to report. diff --git a/imapserver/condstore_test.go b/imapserver/condstore_test.go index cdb751f..c9a3215 100644 --- a/imapserver/condstore_test.go +++ b/imapserver/condstore_test.go @@ -84,6 +84,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) { // Later on, we'll update the second, and delete the third, leaving the first // unmodified. Those messages have modseq 0 in the database. We use append for // convenience, then adjust the records in the database. + // We have a workaround below to prevent triggering the consistency checker. tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx") tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx") tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx") @@ -103,7 +104,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) { tc2.client.Login("mjl@mox.example", "testtest") tc2.client.Select("inbox") - // tc2 is a client with condstore, so with modseq responses. + // tc3 is a client with condstore, so with modseq responses. tc3 := startNoSwitchboard(t) defer tc3.close() tc3.client.Login("mjl@mox.example", "testtest") @@ -349,7 +350,13 @@ func testCondstoreQresync(t *testing.T, qresync bool) { t.Helper() xtc := startNoSwitchboard(t) - defer xtc.close() + // We have modified modseq & createseq to 0 above for testing that case. Don't + // trigger the consistency checker. + store.CheckConsistencyOnClose = false + defer func() { + xtc.close() + store.CheckConsistencyOnClose = true + }() xtc.client.Login("mjl@mox.example", "testtest") fn(xtc) tagcount++ @@ -475,6 +482,12 @@ func testCondstoreQresync(t *testing.T, qresync bool) { imapclient.UntaggedExists(2), imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags, imapclient.FetchModSeq(clientModseq)}}, ) + + // Restore valid modseq/createseq for the consistency checker. + _, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("CreateSeq", int64(0)).UpdateNonzero(store.Message{CreateSeq: 2}) + tcheck(t, err, "updating modseq/createseq to valid values") + _, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("ModSeq", int64(0)).UpdateNonzero(store.Message{ModSeq: 2}) + tcheck(t, err, "updating modseq/createseq to valid values") tc2o.close() tc2o = nil tc3o.close() @@ -519,7 +532,10 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) { xtc.client.Login("mjl@mox.example", "testtest") xtc.transactf("ok", "Select inbox (Condstore)") xtc.transactf("bad", "Uid Fetch 1:* (Flags) (Changedsince 1 Vanished)") + // Prevent triggering the consistency checker, we still have modseq/createseq at 0. + store.CheckConsistencyOnClose = false xtc.close() + store.CheckConsistencyOnClose = true xtc = nil // Check that we get proper vanished responses. @@ -539,7 +555,10 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) { xtc = startNoSwitchboard(t) xtc.client.Login("mjl@mox.example", "testtest") xtc.transactf("bad", "Select inbox (Qresync 1 0)") + // Prevent triggering the consistency checker, we still have modseq/createseq at 0. + store.CheckConsistencyOnClose = false xtc.close() + store.CheckConsistencyOnClose = true xtc = nil tc.transactf("bad", "Select inbox (Qresync (0 1))") // Both args must be > 0. @@ -551,7 +570,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) { tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1:6 1:*)))") // Known uidset cannot have *. tc.transactf("bad", "Select inbox (Qresync (1 1) qresync (1 1))") // Duplicate qresync. - flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent label1 l1 l2 l3 l4 l5 l6 l7 l8`, " ") + flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent l1 l2 l3 l4 l5 l6 l7 l8 label1`, " ") permflags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent \*`, " ") uflags := imapclient.UntaggedFlags(flags) upermflags := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "PERMANENTFLAGS", CodeArg: imapclient.CodeList{Code: "PERMANENTFLAGS", Args: permflags}, More: "x"}} @@ -681,7 +700,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) { tc.transactf("ok", "Select inbox (Qresync (1 9 (1,3,6 1,3,6)))") tc.xuntagged( makeUntagged( - imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full syncronization recommended."}}, + imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, )..., @@ -694,7 +713,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) { tc.transactf("ok", "Select inbox (Qresync (1 18 (1,3,6 1,3,6)))") tc.xuntagged( makeUntagged( - imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full syncronization recommended."}}, + imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, )..., diff --git a/imapserver/fetch.go b/imapserver/fetch.go index 87a6fb3..9b55d76 100644 --- a/imapserver/fetch.go +++ b/imapserver/fetch.go @@ -31,11 +31,12 @@ type fetchCmd struct { changes []store.Change // For updated Seen flag. markSeen bool needFlags bool - needModseq bool // Whether untagged responses needs modseq. - expungeIssued bool // Set if a message cannot be read. Can happen for expunged messages. - modseq store.ModSeq // Initialized on first change, for marking messages as seen. - isUID bool // If this is a UID FETCH command. - hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response. + needModseq bool // Whether untagged responses needs modseq. + expungeIssued bool // Set if a message cannot be read. Can happen for expunged messages. + modseq store.ModSeq // Initialized on first change, for marking messages as seen. + isUID bool // If this is a UID FETCH command. + hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response. + deltaCounts store.MailboxCounts // By marking \Seen, the number of unread/unseen messages will go down. We update counts at the end. // Loaded when first needed, closed when message was processed. m *store.Message // Message currently being processed. @@ -140,7 +141,7 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) { cmd.tx = tx // Ensure the mailbox still exists. - c.xmailboxID(tx, c.mailboxID) + mb := c.xmailboxID(tx, c.mailboxID) var uids []store.UID @@ -235,6 +236,14 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) { mlog.Field("processing uid", mlog.Field("uid", uid)) cmd.process(atts) } + + var zeromc store.MailboxCounts + if cmd.deltaCounts != zeromc { + mb.Add(cmd.deltaCounts) // Unseen/Unread will be <= 0. + err := tx.Update(&mb) + xcheckf(err, "updating mailbox counts") + cmd.changes = append(cmd.changes, mb.ChangeCounts()) + } }) if len(cmd.changes) > 0 { @@ -333,12 +342,15 @@ func (cmd *fetchCmd) process(atts []fetchAtt) { if cmd.markSeen { m := cmd.xensureMessage() + cmd.deltaCounts.Sub(m.MailboxCounts()) + origFlags := m.Flags m.Seen = true + cmd.deltaCounts.Add(m.MailboxCounts()) m.ModSeq = cmd.xmodseq() err := cmd.tx.Update(m) xcheckf(err, "marking message as seen") - cmd.changes = append(cmd.changes, store.ChangeFlags{MailboxID: cmd.mailboxID, UID: cmd.uid, ModSeq: m.ModSeq, Mask: store.Flags{Seen: true}, Flags: m.Flags, Keywords: m.Keywords}) + cmd.changes = append(cmd.changes, m.ChangeFlags(origFlags)) } if cmd.needFlags { diff --git a/imapserver/search_test.go b/imapserver/search_test.go index 4b12ae2..56a06ff 100644 --- a/imapserver/search_test.go +++ b/imapserver/search_test.go @@ -111,6 +111,14 @@ func TestSearch(t *testing.T) { tc.transactf("ok", `search body "Joe"`) tc.xsearch(1) + tc.transactf("ok", `search body "Joe" body "bogus"`) + tc.xsearch() + tc.transactf("ok", `search body "Joe" text "Blurdybloop"`) + tc.xsearch(1) + tc.transactf("ok", `search body "Joe" not text "mox"`) + tc.xsearch(1) + tc.transactf("ok", `search body "Joe" not not body "Joe"`) + tc.xsearch(1) tc.transactf("ok", `search body "this is plain text"`) tc.xsearch(2, 3) tc.transactf("ok", `search body "this is html"`) diff --git a/imapserver/server.go b/imapserver/server.go index 290d9b9..76855ea 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -61,7 +61,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" "golang.org/x/exp/maps" "golang.org/x/exp/slices" - "golang.org/x/text/unicode/norm" "github.com/mjl-/bstore" @@ -1132,33 +1131,11 @@ func (c *conn) ok(tag, cmd string) { // Name is invalid if it contains leading/trailing/double slashes, or when it isn't // unicode-normalized, or when empty or has special characters. func xcheckmailboxname(name string, allowInbox bool) string { - first := strings.SplitN(name, "/", 2)[0] - if strings.EqualFold(first, "inbox") { - if len(name) == len("inbox") && !allowInbox { - xuserErrorf("special mailbox name Inbox not allowed") - } - name = "Inbox" + name[len("Inbox"):] - } - - if norm.NFC.String(name) != name { - xusercodeErrorf("CANNOT", "non-unicode-normalized mailbox names not allowed") - } - - if name == "" { - xusercodeErrorf("CANNOT", "empty mailbox name") - } - if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "//") { - xusercodeErrorf("CANNOT", "bad slashes in mailbox name") - } - for _, c := range name { - switch c { - case '%', '*', '#', '&': - xusercodeErrorf("CANNOT", "character %c not allowed in mailbox name", c) - } - // ../rfc/6855:192 - if c <= 0x1f || c >= 0x7f && c <= 0x9f || c == 0x2028 || c == 0x2029 { - xusercodeErrorf("CANNOT", "control characters not allowed in mailbox name") - } + name, isinbox, err := store.CheckMailboxName(name, allowInbox) + if isinbox { + xuserErrorf("special mailboxname Inbox not allowed") + } else if err != nil { + xusercodeErrorf("CANNOT", err.Error()) } return name } @@ -1217,6 +1194,7 @@ func (c *conn) applyChanges(changes []store.Change, initial bool) { case store.ChangeRemoveMailbox, store.ChangeAddMailbox, store.ChangeRenameMailbox, store.ChangeAddSubscription: n = append(n, change) continue + case store.ChangeMailboxCounts, store.ChangeMailboxSpecialUse, store.ChangeMailboxKeywords: default: panic(fmt.Errorf("missing case for %#v", change)) } @@ -1316,11 +1294,11 @@ func (c *conn) applyChanges(changes []store.Change, initial bool) { c.bwritelinef(`* LIST (\NonExistent) "/" %s`, astring(ch.Name).pack(c)) } case store.ChangeAddMailbox: - c.bwritelinef(`* LIST (%s) "/" %s`, strings.Join(ch.Flags, " "), astring(ch.Name).pack(c)) + c.bwritelinef(`* LIST (%s) "/" %s`, strings.Join(ch.Flags, " "), astring(ch.Mailbox.Name).pack(c)) case store.ChangeRenameMailbox: c.bwritelinef(`* LIST (%s) "/" %s ("OLDNAME" (%s))`, strings.Join(ch.Flags, " "), astring(ch.NewName).pack(c), string0(ch.OldName).pack(c)) case store.ChangeAddSubscription: - c.bwritelinef(`* LIST (\Subscribed) "/" %s`, astring(ch.Name).pack(c)) + c.bwritelinef(`* LIST (%s) "/" %s`, strings.Join(append([]string{`\Subscribed`}, ch.Flags...), " "), astring(ch.Name).pack(c)) default: panic(fmt.Sprintf("internal error, missing case for %#v", change)) } @@ -2097,7 +2075,7 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) { qrmodseq = m.ModSeq.Client() - 1 preVanished = 0 qrknownUIDs = nil - c.bwritelinef("* OK [ALERT] Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full syncronization recommended.") + c.bwritelinef("* OK [ALERT] Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended.") } } else if err != bstore.ErrAbsent { xcheckf(err, "checking old client uid") @@ -2203,27 +2181,14 @@ func (c *conn) cmdCreate(tag, cmd string, p *parser) { c.account.WithWLock(func() { c.xdbwrite(func(tx *bstore.Tx) { - elems := strings.Split(name, "/") - var p string - for i, elem := range elems { - if i > 0 { - p += "/" - } - p += elem - exists, err := c.account.MailboxExists(tx, p) - xcheckf(err, "checking if mailbox exists") - if exists { - if i == len(elems)-1 { - // ../rfc/9051:1914 - xuserErrorf("mailbox already exists") - } - continue - } - _, nchanges, err := c.account.MailboxEnsure(tx, p, true) - xcheckf(err, "ensuring mailbox exists") - changes = append(changes, nchanges...) - created = append(created, p) + var exists bool + var err error + changes, created, exists, err = c.account.MailboxCreate(tx, name) + if exists { + // ../rfc/9051:1914 + xuserErrorf("mailbox already exists") } + xcheckf(err, "creating mailbox") }) c.broadcast(changes) @@ -2255,65 +2220,29 @@ func (c *conn) cmdDelete(tag, cmd string, p *parser) { name = xcheckmailboxname(name, false) // Messages to remove after having broadcasted the removal of messages. - var remove []store.Message + var removeMessageIDs []int64 c.account.WithWLock(func() { var mb store.Mailbox + var changes []store.Change c.xdbwrite(func(tx *bstore.Tx) { mb = c.xmailbox(tx, name, "NONEXISTENT") - // Look for existence of child mailboxes. There is a lot of text in the RFCs about - // NoInferior and NoSelect. We just require only leaf mailboxes are deleted. - qmb := bstore.QueryTx[store.Mailbox](tx) - mbprefix := name + "/" - qmb.FilterFn(func(mb store.Mailbox) bool { - return strings.HasPrefix(mb.Name, mbprefix) - }) - childExists, err := qmb.Exists() - xcheckf(err, "checking child existence") - if childExists { + var hasChildren bool + var err error + changes, removeMessageIDs, hasChildren, err = c.account.MailboxDelete(context.TODO(), c.log, tx, mb) + if hasChildren { xusercodeErrorf("HASCHILDREN", "mailbox has a child, only leaf mailboxes can be deleted") } - - qm := bstore.QueryTx[store.Message](tx) - qm.FilterNonzero(store.Message{MailboxID: mb.ID}) - remove, err = qm.List() - xcheckf(err, "listing messages to remove") - - if len(remove) > 0 { - removeIDs := make([]any, len(remove)) - for i, m := range remove { - removeIDs[i] = m.ID - } - qmr := bstore.QueryTx[store.Recipient](tx) - qmr.FilterEqual("MessageID", removeIDs...) - _, err = qmr.Delete() - xcheckf(err, "removing message recipients for messages") - - qm = bstore.QueryTx[store.Message](tx) - qm.FilterNonzero(store.Message{MailboxID: mb.ID}) - _, err = qm.Delete() - xcheckf(err, "removing messages") - - // Mark messages as not needing training. Then retrain them, so they are untrained if they were. - for i := range remove { - remove[i].Junk = false - remove[i].Notjunk = false - } - err = c.account.RetrainMessages(context.TODO(), c.log, tx, remove, true) - xcheckf(err, "untraining deleted messages") - } - - err = tx.Delete(&store.Mailbox{ID: mb.ID}) - xcheckf(err, "removing mailbox") + xcheckf(err, "deleting mailbox") }) - c.broadcast([]store.Change{store.ChangeRemoveMailbox{Name: name}}) + c.broadcast(changes) }) - for _, m := range remove { - p := c.account.MessagePath(m.ID) + for _, mID := range removeMessageIDs { + p := c.account.MessagePath(mID) err := os.Remove(p) c.log.Check(err, "removing message file for mailbox delete", mlog.Field("path", p)) } @@ -2346,8 +2275,7 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { var changes []store.Change c.xdbwrite(func(tx *bstore.Tx) { - uidval, err := c.account.NextUIDValidity(tx) - xcheckf(err, "next uid validity") + srcMB := c.xmailbox(tx, src, "NONEXISTENT") // Inbox is very special. Unlike other mailboxes, its children are not moved. And // unlike a regular move, its messages are moved to a newly created mailbox. We do @@ -2359,20 +2287,19 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { if exists { xusercodeErrorf("ALREADYEXISTS", "destination mailbox %q already exists", dst) } - srcMB, err := c.account.MailboxFind(tx, src) - xcheckf(err, "finding source mailbox") - if srcMB == nil { - xserverErrorf("inbox not found") - } if dst == src { xuserErrorf("cannot move inbox to itself") } + uidval, err := c.account.NextUIDValidity(tx) + xcheckf(err, "next uid validity") + dstMB := store.Mailbox{ Name: dst, UIDValidity: uidval, UIDNext: 1, Keywords: srcMB.Keywords, + HaveCounts: true, } err = tx.Insert(&dstMB) xcheckf(err, "create new destination mailbox") @@ -2380,6 +2307,8 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { modseq, err := c.account.NextModSeq(tx) xcheckf(err, "assigning next modseq") + changes = make([]store.Change, 2) // Placeholders filled in below. + // Move existing messages, with their ID's and on-disk files intact, to the new // mailbox. We keep the expunged messages, the destination mailbox doesn't care // about them. @@ -2395,6 +2324,10 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { om.PrepareExpunge() oldUIDs = append(oldUIDs, om.UID) + mc := m.MailboxCounts() + srcMB.Sub(mc) + dstMB.Add(mc) + m.MailboxID = dstMB.ID m.UID = dstMB.UIDNext dstMB.UIDNext++ @@ -2404,6 +2337,8 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { return fmt.Errorf("updating message to move to new mailbox: %w", err) } + changes = append(changes, m.ChangeAddUID()) + if err := tx.Insert(&om); err != nil { return fmt.Errorf("adding empty expunge message record to inbox: %w", err) } @@ -2412,109 +2347,32 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) { xcheckf(err, "moving messages from inbox to destination mailbox") err = tx.Update(&dstMB) - xcheckf(err, "updating uidnext in destination mailbox") + xcheckf(err, "updating uidnext and counts in destination mailbox") + + err = tx.Update(&srcMB) + xcheckf(err, "updating counts for inbox") var dstFlags []string if tx.Get(&store.Subscription{Name: dstMB.Name}) == nil { dstFlags = []string{`\Subscribed`} } - changes = []store.Change{ - store.ChangeRemoveUIDs{MailboxID: srcMB.ID, UIDs: oldUIDs, ModSeq: modseq}, - store.ChangeAddMailbox{Name: dstMB.Name, Flags: dstFlags}, - // todo: in future, we could announce all messages. no one is listening now though. - } + changes[0] = store.ChangeRemoveUIDs{MailboxID: srcMB.ID, UIDs: oldUIDs, ModSeq: modseq} + changes[1] = store.ChangeAddMailbox{Mailbox: dstMB, Flags: dstFlags} + // changes[2:...] are ChangeAddUIDs + changes = append(changes, srcMB.ChangeCounts(), dstMB.ChangeCounts()) return } - // We gather existing mailboxes that we need for deciding what to create/delete/update. - q := bstore.QueryTx[store.Mailbox](tx) - srcPrefix := src + "/" - dstRoot := strings.SplitN(dst, "/", 2)[0] - dstRootPrefix := dstRoot + "/" - q.FilterFn(func(mb store.Mailbox) bool { - return mb.Name == src || strings.HasPrefix(mb.Name, srcPrefix) || mb.Name == dstRoot || strings.HasPrefix(mb.Name, dstRootPrefix) - }) - q.SortAsc("Name") // We'll rename the parents before children. - l, err := q.List() - xcheckf(err, "listing relevant mailboxes") - - mailboxes := map[string]store.Mailbox{} - for _, mb := range l { - mailboxes[mb.Name] = mb - } - - if _, ok := mailboxes[src]; !ok { + var notExists, alreadyExists bool + var err error + changes, _, notExists, alreadyExists, err = c.account.MailboxRename(tx, srcMB, dst) + if notExists { // ../rfc/9051:5140 - xusercodeErrorf("NONEXISTENT", "mailbox does not exist") - } - - // Ensure parent mailboxes for the destination paths exist. - var parent string - dstElems := strings.Split(dst, "/") - for i, elem := range dstElems[:len(dstElems)-1] { - if i > 0 { - parent += "/" - } - parent += elem - - mb, ok := mailboxes[parent] - if ok { - continue - } - omb := mb - mb = store.Mailbox{ - ID: omb.ID, - Name: parent, - UIDValidity: uidval, - UIDNext: 1, - } - err = tx.Insert(&mb) - xcheckf(err, "creating parent mailbox") - - if tx.Get(&store.Subscription{Name: parent}) != nil { - err := tx.Insert(&store.Subscription{Name: parent}) - xcheckf(err, "creating subscription") - } - changes = append(changes, store.ChangeAddMailbox{Name: parent, Flags: []string{`\Subscribed`}}) - } - - // Process src mailboxes, renaming them to dst. - for _, srcmb := range l { - if srcmb.Name != src && !strings.HasPrefix(srcmb.Name, srcPrefix) { - continue - } - srcName := srcmb.Name - dstName := dst + srcmb.Name[len(src):] - if _, ok := mailboxes[dstName]; ok { - xusercodeErrorf("ALREADYEXISTS", "destination mailbox %q already exists", dstName) - } - - srcmb.Name = dstName - srcmb.UIDValidity = uidval - err = tx.Update(&srcmb) - xcheckf(err, "renaming mailbox") - - // Renaming Inbox is special, it leaves an empty inbox instead of removing it. - var dstFlags []string - if tx.Get(&store.Subscription{Name: dstName}) == nil { - dstFlags = []string{`\Subscribed`} - } - changes = append(changes, store.ChangeRenameMailbox{OldName: srcName, NewName: dstName, Flags: dstFlags}) - } - - // If we renamed e.g. a/b to a/b/c/d, and a/b/c to a/b/c/d/c, we'll have to recreate a/b and a/b/c. - srcElems := strings.Split(src, "/") - xsrc := src - for i := 0; i < len(dstElems) && strings.HasPrefix(dst, xsrc+"/"); i++ { - mb := store.Mailbox{ - UIDValidity: uidval, - UIDNext: 1, - Name: xsrc, - } - err = tx.Insert(&mb) - xcheckf(err, "creating mailbox at old path") - xsrc += "/" + dstElems[len(srcElems)+i] + xusercodeErrorf("NONEXISTENT", "%s", err) + } else if alreadyExists { + xusercodeErrorf("ALREADYEXISTS", "%s", err) } + xcheckf(err, "renaming mailbox") }) c.broadcast(changes) }) @@ -2711,43 +2569,22 @@ func (c *conn) cmdStatus(tag, cmd string, p *parser) { // Response syntax: ../rfc/9051:6681 ../rfc/9051:7070 ../rfc/9051:7059 ../rfc/3501:4834 func (c *conn) xstatusLine(tx *bstore.Tx, mb store.Mailbox, attrs []string) string { - var count, unseen, deleted int - var size int64 - - // todo optimize: should probably cache the values instead of reading through the database. must then be careful to keep it consistent... - - q := bstore.QueryTx[store.Message](tx) - q.FilterNonzero(store.Message{MailboxID: mb.ID}) - q.FilterEqual("Expunged", false) - err := q.ForEach(func(m store.Message) error { - count++ - if !m.Seen { - unseen++ - } - if m.Deleted { - deleted++ - } - size += m.Size - return nil - }) - xcheckf(err, "processing mailbox messages") - status := []string{} for _, a := range attrs { A := strings.ToUpper(a) switch A { case "MESSAGES": - status = append(status, A, fmt.Sprintf("%d", count)) + status = append(status, A, fmt.Sprintf("%d", mb.Total+mb.Deleted)) case "UIDNEXT": status = append(status, A, fmt.Sprintf("%d", mb.UIDNext)) case "UIDVALIDITY": status = append(status, A, fmt.Sprintf("%d", mb.UIDValidity)) case "UNSEEN": - status = append(status, A, fmt.Sprintf("%d", unseen)) + status = append(status, A, fmt.Sprintf("%d", mb.Unseen)) case "DELETED": - status = append(status, A, fmt.Sprintf("%d", deleted)) + status = append(status, A, fmt.Sprintf("%d", mb.Deleted)) case "SIZE": - status = append(status, A, fmt.Sprintf("%d", size)) + status = append(status, A, fmt.Sprintf("%d", mb.Size)) case "RECENT": status = append(status, A, "0") case "APPENDLIMIT": @@ -2763,36 +2600,6 @@ func (c *conn) xstatusLine(tx *bstore.Tx, mb store.Mailbox, attrs []string) stri return fmt.Sprintf("* STATUS %s (%s)", astring(mb.Name).pack(c), strings.Join(status, " ")) } -func xparseStoreFlags(l []string, syntax bool) (flags store.Flags, keywords []string) { - fields := map[string]*bool{ - `\answered`: &flags.Answered, - `\flagged`: &flags.Flagged, - `\deleted`: &flags.Deleted, - `\seen`: &flags.Seen, - `\draft`: &flags.Draft, - `$junk`: &flags.Junk, - `$notjunk`: &flags.Notjunk, - `$forwarded`: &flags.Forwarded, - `$phishing`: &flags.Phishing, - `$mdnsent`: &flags.MDNSent, - } - seen := map[string]bool{} - for _, f := range l { - f = strings.ToLower(f) - if field, ok := fields[f]; ok { - *field = true - } else if seen[f] { - if moxvar.Pedantic { - xuserErrorf("duplicate keyword %s", f) - } - } else { - keywords = append(keywords, f) - seen[f] = true - } - } - return -} - func flaglist(fl store.Flags, keywords []string) listspace { l := listspace{} flag := func(v bool, s string) { @@ -2831,7 +2638,11 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { var keywords []string if p.hasPrefix("(") { // Error must be a syntax error, to properly abort the connection due to literal. - storeFlags, keywords = xparseStoreFlags(p.xflagList(), true) + var err error + storeFlags, keywords, err = store.ParseFlagsKeywords(p.xflagList()) + if err != nil { + xsyntaxErrorf("parsing flags: %v", err) + } p.xspace() } var tm time.Time @@ -2899,22 +2710,22 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { } var mb store.Mailbox - var msg store.Message + var m store.Message var pendingChanges []store.Change c.account.WithWLock(func() { + var changes []store.Change c.xdbwrite(func(tx *bstore.Tx) { mb = c.xmailbox(tx, name, "TRYCREATE") // Ensure keywords are stored in mailbox. - var changed bool - mb.Keywords, changed = store.MergeKeywords(mb.Keywords, keywords) - if changed { - err := tx.Update(&mb) - xcheckf(err, "updating keywords in mailbox") + var mbKwChanged bool + mb.Keywords, mbKwChanged = store.MergeKeywords(mb.Keywords, keywords) + if mbKwChanged { + changes = append(changes, mb.ChangeKeywords()) } - msg = store.Message{ + m = store.Message{ MailboxID: mb.ID, MailboxOrigID: mb.ID, Received: tm, @@ -2923,8 +2734,15 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { Size: size, MsgPrefix: msgPrefix, } + + mb.Add(m.MailboxCounts()) + + // Update mailbox before delivering, which updates uidnext which we mustn't overwrite. + err = tx.Update(&mb) + xcheckf(err, "updating mailbox counts") + isSent := name == "Sent" - err := c.account.DeliverMessage(c.log, tx, &msg, msgFile, true, isSent, true, false) + err := c.account.DeliverMessage(c.log, tx, &m, msgFile, true, isSent, true, false) xcheckf(err, "delivering message") }) @@ -2934,7 +2752,8 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { } // Broadcast the change to other connections. - c.broadcast([]store.Change{store.ChangeAddUID{MailboxID: mb.ID, UID: msg.UID, ModSeq: msg.ModSeq, Flags: msg.Flags, Keywords: msg.Keywords}}) + changes = append(changes, m.ChangeAddUID(), mb.ChangeCounts()) + c.broadcast(changes) }) err = msgFile.Close() @@ -2943,12 +2762,12 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { if c.mailboxID == mb.ID { c.applyChanges(pendingChanges, false) - c.uidAppend(msg.UID) + c.uidAppend(m.UID) // todo spec: with condstore/qresync, is there a mechanism to the client know the modseq for the appended uid? in theory an untagged fetch with the modseq after the OK APPENDUID could make sense, but this probably isn't allowed. c.bwritelinef("* %d EXISTS", len(c.uids)) } - c.writeresultf("%s OK [APPENDUID %d %d] appended", tag, mb.UIDValidity, msg.UID) + c.writeresultf("%s OK [APPENDUID %d %d] appended", tag, mb.UIDValidity, m.UID) } // Idle makes a client wait until the server sends untagged updates, e.g. about @@ -3058,8 +2877,10 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (remove []store.M var modseq store.ModSeq c.account.WithWLock(func() { + var mb store.Mailbox + c.xdbwrite(func(tx *bstore.Tx) { - mb := store.Mailbox{ID: c.mailboxID} + mb = store.Mailbox{ID: c.mailboxID} err := tx.Get(&mb) if err == bstore.ErrAbsent { if missingMailboxOK { @@ -3095,6 +2916,7 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (remove []store.M for i, m := range remove { removeIDs[i] = m.ID anyIDs[i] = m.ID + mb.Sub(m.MailboxCounts()) } qmr := bstore.QueryTx[store.Recipient](tx) qmr.FilterEqual("MessageID", anyIDs...) @@ -3106,6 +2928,9 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (remove []store.M _, err = qm.UpdateNonzero(store.Message{Expunged: true, ModSeq: modseq}) xcheckf(err, "marking messages marked for deleted as expunged") + err = tx.Update(&mb) + xcheckf(err, "updating mailbox counts") + // Mark expunged messages as not needing training, then retrain them, so if they // were trained, they get untrained. for i := range remove { @@ -3123,7 +2948,10 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (remove []store.M for i, m := range remove { ouids[i] = m.UID } - changes := []store.Change{store.ChangeRemoveUIDs{MailboxID: c.mailboxID, UIDs: ouids, ModSeq: modseq}} + changes := []store.Change{ + store.ChangeRemoveUIDs{MailboxID: c.mailboxID, UIDs: ouids, ModSeq: modseq}, + mb.ChangeCounts(), + } c.broadcast(changes) } }) @@ -3331,6 +3159,8 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) { var modseq store.ModSeq // For messages in new mailbox, assigned when first message is copied. c.account.WithWLock(func() { + var mbKwChanged bool + c.xdbwrite(func(tx *bstore.Tx) { mbSrc := c.xmailboxID(tx, c.mailboxID) // Validate. mbDst = c.xmailbox(tx, name, "TRYCREATE") @@ -3416,17 +3246,14 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) { err := tx.Insert(&mr) xcheckf(err, "inserting message recipient") } + + mbDst.Add(m.MailboxCounts()) } - // Ensure destination mailbox has keywords of the moved messages. - for kw := range mbKeywords { - if !slices.Contains(mbDst.Keywords, kw) { - mbDst.Keywords = append(mbDst.Keywords, kw) - } - } + mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(mbKeywords)) err = tx.Update(&mbDst) - xcheckf(err, "updating destination mailbox for uids and keywords") + xcheckf(err, "updating destination mailbox for uids, keywords and counts") // Copy message files to new message ID's. syncDirs := map[string]struct{}{} @@ -3454,9 +3281,13 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) { // Broadcast changes to other connections. if len(newUIDs) > 0 { - changes := make([]store.Change, len(newUIDs)) + changes := make([]store.Change, 0, len(newUIDs)+2) for i, uid := range newUIDs { - changes[i] = store.ChangeAddUID{MailboxID: mbDst.ID, UID: uid, ModSeq: modseq, Flags: flags[i], Keywords: keywords[i]} + changes = append(changes, store.ChangeAddUID{MailboxID: mbDst.ID, UID: uid, ModSeq: modseq, Flags: flags[i], Keywords: keywords[i]}) + } + changes = append(changes, mbDst.ChangeCounts()) + if mbKwChanged { + changes = append(changes, mbDst.ChangeKeywords()) } c.broadcast(changes) } @@ -3490,14 +3321,14 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) { uids, uidargs := c.gatherCopyMoveUIDs(isUID, nums) - var mbDst store.Mailbox + var mbSrc, mbDst store.Mailbox var changes []store.Change var newUIDs []store.UID var modseq store.ModSeq c.account.WithWLock(func() { c.xdbwrite(func(tx *bstore.Tx) { - mbSrc := c.xmailboxID(tx, c.mailboxID) // Validate. + mbSrc = c.xmailboxID(tx, c.mailboxID) // Validate. mbDst = c.xmailbox(tx, name, "TRYCREATE") if mbDst.ID == c.mailboxID { xuserErrorf("cannot move to currently selected mailbox") @@ -3542,6 +3373,10 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) { xserverErrorf("internal error: got uid %d, expected %d, for index %d", m.UID, uids[i], i) } + mc := m.MailboxCounts() + mbSrc.Sub(mc) + mbDst.Add(mc) + // Copy of message record that we'll insert when UID is freed up. om := *m om.PrepareExpunge() @@ -3571,25 +3406,29 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) { } // Ensure destination mailbox has keywords of the moved messages. - for kw := range keywords { - if !slices.Contains(mbDst.Keywords, kw) { - mbDst.Keywords = append(mbDst.Keywords, kw) - } + var mbKwChanged bool + mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords)) + if mbKwChanged { + changes = append(changes, mbDst.ChangeKeywords()) } + err = tx.Update(&mbSrc) + xcheckf(err, "updating source mailbox counts") + err = tx.Update(&mbDst) - xcheckf(err, "updating destination mailbox for uids and keywords") + xcheckf(err, "updating destination mailbox for uids, keywords and counts") err = c.account.RetrainMessages(context.TODO(), c.log, tx, msgs, false) xcheckf(err, "retraining messages after move") // Prepare broadcast changes to other connections. - changes = make([]store.Change, 0, 1+len(msgs)) + changes = make([]store.Change, 0, 1+len(msgs)+2) changes = append(changes, store.ChangeRemoveUIDs{MailboxID: c.mailboxID, UIDs: uids, ModSeq: modseq}) for _, m := range msgs { newUIDs = append(newUIDs, m.UID) - changes = append(changes, store.ChangeAddUID{MailboxID: mbDst.ID, UID: m.UID, ModSeq: modseq, Flags: m.Flags, Keywords: m.Keywords}) + changes = append(changes, m.ChangeAddUID()) } + changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts()) }) c.broadcast(changes) @@ -3670,7 +3509,10 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { xuserErrorf("mailbox open in read-only mode") } - flags, keywords := xparseStoreFlags(flagstrs, false) + flags, keywords, err := store.ParseFlagsKeywords(flagstrs) + if err != nil { + xuserErrorf("parsing flags: %v", err) + } var mask store.Flags if plus { mask, flags = flags, store.FlagsAll @@ -3680,14 +3522,19 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { mask = store.FlagsAll } + var mb, origmb store.Mailbox var updated []store.Message var changed []store.Message // ModSeq more recent than unchangedSince, will be in MODIFIED response code, and we will send untagged fetch responses so client is up to date. var modseq store.ModSeq // Assigned when needed. modified := map[int64]bool{} c.account.WithWLock(func() { + var mbKwChanged bool + var changes []store.Change + c.xdbwrite(func(tx *bstore.Tx) { - mb := c.xmailboxID(tx, c.mailboxID) // Validate. + mb = c.xmailboxID(tx, c.mailboxID) // Validate. + origmb = mb uidargs := c.xnumSetCondition(isUID, nums) @@ -3697,9 +3544,8 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { // Ensure keywords are in mailbox. if !minus { - var changed bool - mb.Keywords, changed = store.MergeKeywords(mb.Keywords, keywords) - if changed { + mb.Keywords, mbKwChanged = store.MergeKeywords(mb.Keywords, keywords) + if mbKwChanged { err := tx.Update(&mb) xcheckf(err, "updating mailbox with keywords") } @@ -3715,11 +3561,13 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { return nil } + mc := m.MailboxCounts() + origFlags := m.Flags m.Flags = m.Flags.Set(mask, flags) oldKeywords := append([]string{}, m.Keywords...) if minus { - m.Keywords = store.RemoveKeywords(m.Keywords, keywords) + m.Keywords, _ = store.RemoveKeywords(m.Keywords, keywords) } else if plus { m.Keywords, _ = store.MergeKeywords(m.Keywords, keywords) } else { @@ -3760,6 +3608,9 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { return nil } + mb.Sub(mc) + mb.Add(m.MailboxCounts()) + // Assign new modseq for first actual change. if modseq == 0 { var err error @@ -3769,26 +3620,28 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) { m.ModSeq = modseq modified[m.ID] = true updated = append(updated, m) + + changes = append(changes, m.ChangeFlags(origFlags)) + return tx.Update(&m) }) xcheckf(err, "storing flags in messages") + if mb.MailboxCounts != origmb.MailboxCounts { + err := tx.Update(&mb) + xcheckf(err, "updating mailbox counts") + + changes = append(changes, mb.ChangeCounts()) + } + if mbKwChanged { + changes = append(changes, mb.ChangeKeywords()) + } + err = c.account.RetrainMessages(context.TODO(), c.log, tx, updated, false) xcheckf(err, "training messages") }) - // Broadcast changes to other connections. - changes := make([]store.Change, 0, len(updated)) - for _, m := range updated { - // We only notify about flags that actually changed. - if m.ModSeq == modseq { - ch := store.ChangeFlags{MailboxID: m.MailboxID, UID: m.UID, ModSeq: modseq, Mask: mask, Flags: m.Flags, Keywords: m.Keywords} - changes = append(changes, ch) - } - } - if len(changes) > 0 { - c.broadcast(changes) - } + c.broadcast(changes) }) // In the RFC, the section about STORE/UID STORE says we must return MODSEQ when diff --git a/imapserver/server_test.go b/imapserver/server_test.go index 408559e..3d3ed09 100644 --- a/imapserver/server_test.go +++ b/imapserver/server_test.go @@ -301,8 +301,13 @@ func (tc *testconn) waitDone() { } func (tc *testconn) close() { + if tc.account == nil { + // Already closed, we are not strict about closing multiple times. + return + } err := tc.account.Close() tc.check(err, "close account") + tc.account = nil tc.client.Close() tc.serverConn.Close() tc.waitDone() diff --git a/imapserver/store_test.go b/imapserver/store_test.go index 89484fa..b771179 100644 --- a/imapserver/store_test.go +++ b/imapserver/store_test.go @@ -58,9 +58,9 @@ func TestStore(t *testing.T) { tc.transactf("ok", "store 1 flags (new)") // New flag. tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"new"}}}) tc.transactf("ok", "store 1 flags (new new a b c)") // Duplicates are ignored. - tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"new", "a", "b", "c"}}}) + tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"a", "b", "c", "new"}}}) tc.transactf("ok", "store 1 +flags (new new c d e)") - tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"new", "a", "b", "c", "d", "e"}}}) + tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"a", "b", "c", "d", "e", "new"}}}) tc.transactf("ok", "store 1 -flags (new new e a c)") tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"b", "d"}}}) tc.transactf("ok", "store 1 flags ($Forwarded Different)") @@ -77,7 +77,7 @@ func TestStore(t *testing.T) { tc.transactf("ok", "examine inbox") // Open read-only. // Flags are added to mailbox, not removed. - flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent new a b c d e different`, " ") + flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent a b c d different e new`, " ") tc.xuntaggedOpt(false, imapclient.UntaggedFlags(flags)) tc.transactf("no", `store 1 flags ()`) // No permission to set flags. diff --git a/import.go b/import.go index ba5f845..4afe42e 100644 --- a/import.go +++ b/import.go @@ -283,7 +283,7 @@ func importctl(ctx context.Context, ctl *ctl, mbox bool) { ctl.xcheck(err, "delivering message") deliveredIDs = append(deliveredIDs, m.ID) ctl.log.Debug("delivered message", mlog.Field("id", m.ID)) - changes = append(changes, store.ChangeAddUID{MailboxID: m.MailboxID, UID: m.UID, ModSeq: modseq, Flags: m.Flags, Keywords: m.Keywords}) + changes = append(changes, m.ChangeAddUID()) } // todo: one goroutine for reading messages, one for parsing the message, one adding to database, one for junk filter training. @@ -324,6 +324,7 @@ func importctl(ctx context.Context, ctl *ctl, mbox bool) { for _, kw := range m.Keywords { mailboxKeywords[kw] = true } + mb.Add(m.MailboxCounts()) // Parse message and store parsed information for later fast retrieval. p, err := message.EnsurePart(msgf, m.Size) @@ -386,18 +387,23 @@ func importctl(ctx context.Context, ctl *ctl, mbox bool) { process(m, msgf, origPath) } - // Load the mailbox again after delivering, its uidnext has been updated. + // Get mailbox again, uidnext is likely updated. + mc := mb.MailboxCounts err = tx.Get(&mb) - ctl.xcheck(err, "fetching mailbox") + ctl.xcheck(err, "get mailbox") + mb.MailboxCounts = mc // If there are any new keywords, update the mailbox. - var changed bool - mb.Keywords, changed = store.MergeKeywords(mb.Keywords, maps.Keys(mailboxKeywords)) - if changed { - err := tx.Update(&mb) - ctl.xcheck(err, "updating keywords in mailbox") + var mbKwChanged bool + mb.Keywords, mbKwChanged = store.MergeKeywords(mb.Keywords, maps.Keys(mailboxKeywords)) + if mbKwChanged { + changes = append(changes, mb.ChangeKeywords()) } + err = tx.Update(&mb) + ctl.xcheck(err, "updating message counts and keywords in mailbox") + changes = append(changes, mb.ChangeCounts()) + err = tx.Commit() ctl.xcheck(err, "commit") tx = nil diff --git a/localserve.go b/localserve.go index 7af283f..1553f46 100644 --- a/localserve.go +++ b/localserve.go @@ -78,6 +78,7 @@ during those commands instead of during "data". mox.FilesImmediate = true // Load config, creating a new one if needed. + var existingConfig bool if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) { err := writeLocalConfig(log, dir, ip) if err != nil { @@ -89,6 +90,8 @@ during those commands instead of during "data". log.Fatalx("loading mox localserve config (hint: when creating a new config with -dir, the directory must not yet exist)", err, mlog.Field("dir", dir)) } else if ip != "" { log.Fatal("can only use -ip when writing a new config file") + } else { + existingConfig = true } if level, ok := mlog.Levels[loglevel]; loglevel != "" && ok { @@ -147,10 +150,17 @@ during those commands instead of during "data". golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)") golog.Print("https://mox%40localhost:moxmoxmox@localhost:1443/account/ - account https") golog.Print(" http://mox%40localhost:moxmoxmox@localhost:1080/account/ - account http (without tls)") + golog.Print("https://mox%40localhost:moxmoxmox@localhost:1443/webmail/ - webmail https") + golog.Print(" http://mox%40localhost:moxmoxmox@localhost:1080/webmail/ - webmail http (without tls)") golog.Print("https://admin:moxadmin@localhost:1443/admin/ - admin https") golog.Print(" http://admin:moxadmin@localhost:1080/admin/ - admin http (without tls)") golog.Print("") - golog.Printf("serving from %s", dir) + if existingConfig { + golog.Printf("serving from existing config dir %s/", dir) + golog.Printf("if urls above don't work, consider resetting by removing config dir") + } else { + golog.Printf("serving from newly created config dir %s/", dir) + } ctlpath := mox.DataDirPath("ctl") _ = os.Remove(ctlpath) @@ -294,6 +304,12 @@ func writeLocalConfig(log *mlog.Log, dir, ip string) (rerr error) { local.AccountHTTPS.Enabled = true local.AccountHTTPS.Port = 1443 local.AccountHTTPS.Path = "/account/" + local.WebmailHTTP.Enabled = true + local.WebmailHTTP.Port = 1080 + local.WebmailHTTP.Path = "/webmail/" + local.WebmailHTTPS.Enabled = true + local.WebmailHTTPS.Port = 1443 + local.WebmailHTTPS.Path = "/webmail/" local.AdminHTTP.Enabled = true local.AdminHTTP.Port = 1080 local.AdminHTTPS.Enabled = true diff --git a/main.go b/main.go index c7e035b..42786af 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,6 @@ import ( "github.com/mjl-/mox/dmarcrpt" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/dnsbl" - "github.com/mjl-/mox/http" "github.com/mjl-/mox/message" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" @@ -45,6 +44,7 @@ import ( "github.com/mjl-/mox/tlsrpt" "github.com/mjl-/mox/tlsrptdb" "github.com/mjl-/mox/updates" + "github.com/mjl-/mox/webadmin" ) var ( @@ -143,6 +143,7 @@ var commands = []struct { {"reassignuids", cmdReassignUIDs}, {"fixuidmeta", cmdFixUIDMeta}, {"dmarcdb addreport", cmdDMARCDBAddReport}, + {"reparse", cmdReparse}, {"ensureparsed", cmdEnsureParsed}, {"message parse", cmdMessageParse}, {"tlsrptdb addreport", cmdTLSRPTDBAddReport}, @@ -154,6 +155,7 @@ var commands = []struct { {"gentestdata", cmdGentestdata}, {"ximport maildir", cmdXImportMaildir}, {"ximport mbox", cmdXImportMbox}, + {"recalculatemailboxcounts", cmdRecalculateMailboxCounts}, } var cmds []cmd @@ -376,6 +378,11 @@ func mustLoadConfig() { } func main() { + // CheckConsistencyOnClose is true by default, for all the test packages. A regular + // mox server should never use it. But integration tests enable it again with a + // flag. + store.CheckConsistencyOnClose = false + log.SetFlags(0) // If invoked as sendmail, e.g. /usr/sbin/sendmail, we do enough so cron can get a @@ -392,6 +399,7 @@ func main() { flag.StringVar(&mox.ConfigStaticPath, "config", envString("MOXCONF", "config/mox.conf"), "configuration file, other config files are looked up in the same directory, defaults to $MOXCONF with a fallback to mox.conf") flag.StringVar(&loglevel, "loglevel", "", "if non-empty, this log level is set early in startup") flag.BoolVar(&pedantic, "pedantic", false, "protocol violations result in errors instead of accepting/working around them") + flag.BoolVar(&store.CheckConsistencyOnClose, "checkconsistency", false, "dangerous option for testing only, enables data checks that abort/panic when inconsistencies are found") var cpuprofile, memprofile string flag.StringVar(&cpuprofile, "cpuprof", "", "store cpu profile to file") @@ -777,7 +785,7 @@ func cmdConfigDNSCheck(c *cmd) { log.Fatalf("%s", err) }() - printResult := func(name string, r http.Result) { + printResult := func(name string, r webadmin.Result) { if len(r.Errors) == 0 && len(r.Warnings) == 0 { return } @@ -790,7 +798,7 @@ func cmdConfigDNSCheck(c *cmd) { } } - result := http.Admin{}.CheckDomain(context.Background(), args[0]) + result := webadmin.Admin{}.CheckDomain(context.Background(), args[0]) printResult("IPRev", result.IPRev.Result) printResult("MX", result.MX.Result) printResult("TLS", result.TLS.Result) @@ -1980,6 +1988,30 @@ func cmdVersion(c *cmd) { fmt.Println(moxvar.Version) } +func cmdReparse(c *cmd) { + c.unlisted = true + c.params = "[account]" + c.help = "Ensure messages in the database have a ParsedBuf." + args := c.Parse() + if len(args) > 1 { + c.Usage() + } + + mustLoadConfig() + var account string + if len(args) == 1 { + account = args[0] + } + ctlcmdReparse(xctl(), account) +} + +func ctlcmdReparse(ctl *ctl, account string) { + ctl.xwrite("reparse") + ctl.xwrite(account) + ctl.xreadok() + ctl.xstreamto(os.Stdout) +} + func cmdEnsureParsed(c *cmd) { c.unlisted = true c.params = "account" @@ -2268,3 +2300,29 @@ open, or is not running. }) xcheckf(err, "updating database") } + +func cmdRecalculateMailboxCounts(c *cmd) { + c.unlisted = true + c.params = "account" + c.help = `Recalculate message counts for all mailboxes in the account. + +When a message is added to/removed from a mailbox, or when message flags change, +the total, unread, unseen and deleted messages are accounted, and the total size +of the mailbox. In case of a bug in this accounting, the numbers could become +incorrect. This command will find, fix and print them. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + mustLoadConfig() + ctlcmdRecalculateMailboxCounts(xctl(), args[0]) +} + +func ctlcmdRecalculateMailboxCounts(ctl *ctl, account string) { + ctl.xwrite("recalculatemailboxcounts") + ctl.xwrite(account) + ctl.xreadok() + ctl.xstreamto(os.Stdout) +} diff --git a/smtpserver/authresults.go b/message/authresults.go similarity index 91% rename from smtpserver/authresults.go rename to message/authresults.go index 5b2d13b..9eded7a 100644 --- a/smtpserver/authresults.go +++ b/message/authresults.go @@ -1,9 +1,7 @@ -package smtpserver +package message import ( "fmt" - - "github.com/mjl-/mox/message" ) // ../rfc/8601:577 @@ -46,6 +44,11 @@ type AuthProp struct { Comment string // If not empty, header comment withtout "()", added after Value. } +// MakeAuthProp is a convenient way to make an AuthProp. +func MakeAuthProp(typ, property, value string, isAddrLike bool, Comment string) AuthProp { + return AuthProp{typ, property, value, isAddrLike, Comment} +} + // todo future: we could store fields as dns.Domain, and when we encode as non-ascii also add the ascii version as a comment. // Header returns an Authentication-Results header, possibly spanning multiple @@ -60,7 +63,7 @@ func (h AuthResults) Header() string { return s } - w := &message.HeaderWriter{} + w := &HeaderWriter{} w.Add("", "Authentication-Results:"+optComment(h.Comment)+" "+value(h.Hostname)+";") for i, m := range h.Methods { tokens := []string{} diff --git a/smtpserver/authresults_test.go b/message/authresults_test.go similarity index 97% rename from smtpserver/authresults_test.go rename to message/authresults_test.go index 63a1de0..2a8bc59 100644 --- a/smtpserver/authresults_test.go +++ b/message/authresults_test.go @@ -1,4 +1,4 @@ -package smtpserver +package message import ( "testing" diff --git a/message/hdrcmtdomain.go b/message/hdrcmtdomain.go new file mode 100644 index 0000000..ff7cd63 --- /dev/null +++ b/message/hdrcmtdomain.go @@ -0,0 +1,21 @@ +package message + +import ( + "github.com/mjl-/mox/dns" +) + +// HeaderCommentDomain returns domain name optionally followed by a message +// header comment with ascii-only name. +// +// The comment is only present when smtputf8 is true and the domain name is unicode. +// +// Caller should make sure the comment is allowed in the syntax. E.g. for Received, +// it is often allowed before the next field, so make sure such a next field is +// present. +func HeaderCommentDomain(domain dns.Domain, smtputf8 bool) string { + s := domain.XName(smtputf8) + if smtputf8 && domain.Unicode != "" { + s += " (" + domain.ASCII + ")" + } + return s +} diff --git a/message/part.go b/message/part.go index 5ded570..f9786a2 100644 --- a/message/part.go +++ b/message/part.go @@ -85,6 +85,10 @@ type Part struct { bound []byte // Only set if valid multipart with boundary, includes leading --, excludes \r\n. } +// todo: have all Content* fields in Part? +// todo: make Address contain a type Localpart and dns.Domain? +// todo: if we ever make a major change and reparse all parts, switch to lower-case values if not too troublesome. + // Envelope holds the basic/common message headers as used in IMAP4. type Envelope struct { Date time.Time diff --git a/message/qp.go b/message/qp.go new file mode 100644 index 0000000..b1cdd30 --- /dev/null +++ b/message/qp.go @@ -0,0 +1,17 @@ +package message + +import ( + "strings" +) + +// NeedsQuotedPrintable returns whether text should be encoded with +// quoted-printable. If not, it can be included as 7bit or 8bit encoding. +func NeedsQuotedPrintable(text string) bool { + // ../rfc/2045:1025 + for _, line := range strings.Split(text, "\r\n") { + if len(line) > 78 || strings.Contains(line, "\r") || strings.Contains(line, "\n") { + return true + } + } + return false +} diff --git a/message/tlsrecv.go b/message/tlsrecv.go new file mode 100644 index 0000000..0193a7b --- /dev/null +++ b/message/tlsrecv.go @@ -0,0 +1,46 @@ +package message + +import ( + "crypto/tls" + "fmt" + + "github.com/mjl-/mox/mlog" +) + +// TLSReceivedComment returns a comment about TLS of the connection for use in a Receive header. +func TLSReceivedComment(log *mlog.Log, cs tls.ConnectionState) []string { + // todo future: we could use the "tls" clause for the Received header as specified in ../rfc/8314:496. however, the text implies it is only for submission, not regular smtp. and it cannot specify the tls version. for now, not worth the trouble. + + // Comments from other mail servers: + // gmail.com: (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128) + // yahoo.com: (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256) + // proton.me: (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256) (No client certificate requested) + // outlook.com: (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) + + var l []string + add := func(s string) { + l = append(l, s) + } + + versions := map[uint16]string{ + tls.VersionTLS10: "TLS1.0", + tls.VersionTLS11: "TLS1.1", + tls.VersionTLS12: "TLS1.2", + tls.VersionTLS13: "TLS1.3", + } + + if version, ok := versions[cs.Version]; ok { + add(version) + } else { + log.Info("unknown tls version identifier", mlog.Field("version", cs.Version)) + add(fmt.Sprintf("TLS identifier %x", cs.Version)) + } + + add(tls.CipherSuiteName(cs.CipherSuite)) + + // Make it a comment. + l[0] = "(" + l[0] + l[len(l)-1] = l[len(l)-1] + ")" + + return l +} diff --git a/metrics/auth.go b/metrics/auth.go index 0a2494b..e777300 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -12,7 +12,7 @@ var ( Help: "Authentication attempts and results.", }, []string{ - "kind", // submission, imap, httpaccount, httpadmin + "kind", // submission, imap, webmail, webaccount, webadmin (formerly httpaccount, httpadmin) "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, httpbasic // todo: we currently only use badcreds, but known baduser can be helpful "result", // ok, baduser, badpassword, badcreds, error, aborted diff --git a/mox-/admin.go b/mox-/admin.go index f35d536..1e47df2 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -776,6 +776,42 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { return nil } +// AccountFullNameSave updates the full name for an account and reloads the configuration. +func AccountFullNameSave(ctx context.Context, account, fullName string) (rerr error) { + log := xlog.WithContext(ctx) + defer func() { + if rerr != nil { + log.Errorx("saving account full name", rerr, mlog.Field("account", account)) + } + }() + + Conf.dynamicMutex.Lock() + defer Conf.dynamicMutex.Unlock() + + c := Conf.Dynamic + acc, ok := c.Accounts[account] + if !ok { + return fmt.Errorf("account not present") + } + + // Compose new config without modifying existing data structures. If we fail, we + // leave no trace. + nc := c + nc.Accounts = map[string]config.Account{} + for name, a := range c.Accounts { + nc.Accounts[name] = a + } + + acc.FullName = fullName + nc.Accounts[account] = acc + + if err := writeDynamic(ctx, log, nc); err != nil { + return fmt.Errorf("writing domains.conf: %v", err) + } + log.Info("account full name saved", mlog.Field("account", account)) + return nil +} + // DestinationSave updates a destination for an account and reloads the configuration. func DestinationSave(ctx context.Context, account, destName string, newDest config.Destination) (rerr error) { log := xlog.WithContext(ctx) diff --git a/moxio/base64writer.go b/moxio/base64writer.go new file mode 100644 index 0000000..be54921 --- /dev/null +++ b/moxio/base64writer.go @@ -0,0 +1,73 @@ +package moxio + +import ( + "encoding/base64" + "io" +) + +// implement io.Closer +type closerFunc func() error + +func (f closerFunc) Close() error { + return f() +} + +// Base64Writer turns a writer for data into one that writes base64 content on +// \r\n separated lines of max 78+2 characters length. +func Base64Writer(w io.Writer) io.WriteCloser { + lw := &lineWrapper{w: w} + bw := base64.NewEncoder(base64.StdEncoding, lw) + return struct { + io.Writer + io.Closer + }{ + Writer: bw, + Closer: closerFunc(func() error { + if err := bw.Close(); err != nil { + return err + } + return lw.Close() + }), + } +} + +type lineWrapper struct { + w io.Writer + n int // Written on current line. +} + +func (lw *lineWrapper) Write(buf []byte) (int, error) { + wrote := 0 + for len(buf) > 0 { + n := 78 - lw.n + if n > len(buf) { + n = len(buf) + } + nn, err := lw.w.Write(buf[:n]) + if nn > 0 { + wrote += nn + buf = buf[nn:] + } + if err != nil { + return wrote, err + } + lw.n += nn + if lw.n == 78 { + _, err := lw.w.Write([]byte("\r\n")) + if err != nil { + return wrote, err + } + lw.n = 0 + } + } + return wrote, nil +} + +func (lw *lineWrapper) Close() error { + if lw.n > 0 { + lw.n = 0 + _, err := lw.w.Write([]byte("\r\n")) + return err + } + return nil +} diff --git a/moxio/base64writer_test.go b/moxio/base64writer_test.go new file mode 100644 index 0000000..64c0f40 --- /dev/null +++ b/moxio/base64writer_test.go @@ -0,0 +1,20 @@ +package moxio + +import ( + "strings" + "testing" +) + +func TestBase64Writer(t *testing.T) { + var sb strings.Builder + bw := Base64Writer(&sb) + _, err := bw.Write([]byte("0123456789012345678901234567890123456789012345678901234567890123456789")) + tcheckf(t, err, "write") + err = bw.Close() + tcheckf(t, err, "close") + s := sb.String() + exp := "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nz\r\ng5MDEyMzQ1Njc4OQ==\r\n" + if s != exp { + t.Fatalf("base64writer, got %q, expected %q", s, exp) + } +} diff --git a/moxio/decode_test.go b/moxio/decode_test.go new file mode 100644 index 0000000..0c165c5 --- /dev/null +++ b/moxio/decode_test.go @@ -0,0 +1,24 @@ +package moxio + +import ( + "io" + "strings" + "testing" +) + +func TestDecodeReader(t *testing.T) { + check := func(charset, input, output string) { + t.Helper() + buf, err := io.ReadAll(DecodeReader(charset, strings.NewReader(input))) + tcheckf(t, err, "decode") + if string(buf) != output { + t.Fatalf("decoding %q with charset %q, got %q, expected %q", input, charset, buf, output) + } + } + + check("", "☺", "☺") // No decoding. + check("us-ascii", "☺", "☺") // No decoding. + check("utf-8", "☺", "☺") + check("iso-8859-1", string([]byte{0xa9}), "©") + check("iso-8859-5", string([]byte{0xd0}), "а") +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d520d9f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,316 @@ +{ + "name": "mox", + "lockfileVersion": 3, + "requires": true, + "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", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "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 new file mode 100644 index 0000000..880fe1a --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "jshint": "2.13.6", + "typescript": "5.1.6" + } +} diff --git a/prometheus.rules b/prometheus.rules index 89674b1..7adfd7e 100644 --- a/prometheus.rules +++ b/prometheus.rules @@ -38,6 +38,16 @@ groups: annotations: summary: smtp delivery errors + - alert: mox-webmail-errors + expr: increase(mox_webmail_errors_total[1h]) > 0 + annotations: + summary: errors in webmail operation + + - alert: mox-webmailsubmission-errors + expr: increase(mox_webmail_submission_total{result=~".*error"}[1h]) > 0 + annotations: + summary: webmail submission errors + # the alerts below can be used to keep a closer eye or when starting to use mox, # but can be noisy, or you may not be able to prevent them. diff --git a/queue/queue.go b/queue/queue.go index 439805c..87a8f4a 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -204,8 +204,28 @@ func Add(ctx context.Context, log *mlog.Log, senderAccount string, mailFrom, rcp // todo: Add should accept multiple rcptTo if they are for the same domain. so we can queue them for delivery in one (or just a few) session(s), transferring the data only once. ../rfc/5321:3759 if Localserve { - // Safety measure, shouldn't happen. - return 0, fmt.Errorf("no queuing with localserve") + if senderAccount == "" { + return 0, fmt.Errorf("cannot queue with localserve without local account") + } + acc, err := store.OpenAccount(senderAccount) + if err != nil { + return 0, fmt.Errorf("opening sender account for immediate delivery with localserve: %v", err) + } + defer func() { + err := acc.Close() + log.Check(err, "closing account") + }() + m := store.Message{Size: size} + conf, _ := acc.Conf() + dest := conf.Destinations[mailFrom.String()] + acc.WithWLock(func() { + err = acc.Deliver(log, dest, &m, msgFile, consumeFile) + }) + if err != nil { + return 0, fmt.Errorf("delivering message: %v", err) + } + log.Debug("immediately delivered from queue to sender") + return 0, nil } tx, err := DB.Begin(ctx, true) diff --git a/quickstart.go b/quickstart.go index a0f4259..ee1da69 100644 --- a/quickstart.go +++ b/quickstart.go @@ -530,9 +530,11 @@ listed in more DNS block lists, visit: internal.AccountHTTP.Enabled = true internal.AdminHTTP.Enabled = true internal.MetricsHTTP.Enabled = true + internal.WebmailHTTP.Enabled = true if existingWebserver { internal.AccountHTTP.Port = 1080 internal.AdminHTTP.Port = 1080 + internal.WebmailHTTP.Port = 1080 internal.AutoconfigHTTPS.Enabled = true internal.AutoconfigHTTPS.Port = 81 internal.AutoconfigHTTPS.NonTLS = true @@ -754,8 +756,9 @@ starting up. On linux, you may want to enable mox as a systemd service. fmt.Printf(` After starting mox, the web interfaces are served at: -http://localhost/ - account (email address as username) -http://localhost/admin/ - admin (empty username) +http://localhost/ - account (email address as username) +http://localhost/webmail/ - webmail (email address as username) +http://localhost/admin/ - admin (empty username) To access these from your browser, run "ssh -L 8080:localhost:80 you@yourmachine" locally and open diff --git a/rfc/index.md b/rfc/index.md index d9b3551..4e922bf 100644 --- a/rfc/index.md +++ b/rfc/index.md @@ -4,10 +4,12 @@ Also see IANA assignments, https://www.iana.org/protocols # Mail, message format, MIME 822 Standard for ARPA Internet Text Messages +1847 Security Multiparts for MIME: Multipart/Signed and Multipart/Encrypted 2045 Multipurpose Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies 2046 Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types 2047 MIME (Multipurpose Internet Mail Extensions) Part Three: Message Header Extensions for Non-ASCII Text 2049 Multipurpose Internet Mail Extensions (MIME) Part Five: Conformance Criteria and Examples +2183 Communicating Presentation Information in Internet Messages: The Content-Disposition Header Field 2231 MIME Parameter Value and Encoded Word Extensions: Character Sets, Languages, and Continuations 3629 UTF-8, a transformation format of ISO 10646 3834 Recommendations for Automatic Responses to Electronic Mail @@ -18,6 +20,8 @@ Also see IANA assignments, https://www.iana.org/protocols 7405 Case-Sensitive String Support in ABNF 9228 Delivered-To Email Header Field +https://www.iana.org/assignments/message-headers/message-headers.xhtml + # SMTP 821 (obsoleted by RFC 2821) SIMPLE MAIL TRANSFER PROTOCOL diff --git a/smtpserver/reputation_test.go b/smtpserver/reputation_test.go index 52a9dce..d58815b 100644 --- a/smtpserver/reputation_test.go +++ b/smtpserver/reputation_test.go @@ -107,12 +107,14 @@ func TestReputation(t *testing.T) { defer db.Close() err = db.Write(ctxbg, func(tx *bstore.Tx) error { - err = tx.Insert(&store.Mailbox{ID: 1, Name: "Inbox"}) + inbox := store.Mailbox{ID: 1, Name: "Inbox", HaveCounts: true} + err = tx.Insert(&inbox) tcheck(t, err, "insert into db") for _, hm := range history { err := tx.Insert(&hm) tcheck(t, err, "insert message") + inbox.Add(hm.MailboxCounts()) rcptToDomain, err := dns.ParseDomain(hm.RcptToDomain) tcheck(t, err, "parse rcptToDomain") @@ -121,6 +123,8 @@ func TestReputation(t *testing.T) { err = tx.Insert(&r) tcheck(t, err, "insert recipient") } + err = tx.Update(&inbox) + tcheck(t, err, "update mailbox counts") return nil }) diff --git a/smtpserver/server.go b/smtpserver/server.go index 0db3fd3..743eebd 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -52,8 +52,6 @@ import ( "github.com/mjl-/mox/tlsrptdb" ) -const defaultMaxMsgSize = 100 * 1024 * 1024 - // Most logging should be done through conn.log* functions. // Only use log in contexts without connection. var xlog = mlog.New("smtpserver") @@ -144,10 +142,11 @@ var ( "reason", }, ) + // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission metricSubmission = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "mox_smtpserver_submission_total", - Help: "SMTP server incoming message submissions queue.", + Help: "SMTP server incoming submission results, known values (those ending with error are server errors): ok, badmessage, badfrom, badheader, messagelimiterror, recipientlimiterror, localserveerror, queueerror.", }, []string{ "result", @@ -156,7 +155,7 @@ var ( metricServerErrors = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "mox_smtpserver_errors_total", - Help: "SMTP server errors, known error values: dkimsign, queuedsn.", + Help: "SMTP server errors, known values: dkimsign, queuedsn.", }, []string{ "error", @@ -184,7 +183,7 @@ func Listen() { maxMsgSize := listener.SMTPMaxMessageSize if maxMsgSize == 0 { - maxMsgSize = defaultMaxMsgSize + maxMsgSize = config.DefaultMaxMsgSize } if listener.SMTP.Enabled { @@ -1228,7 +1227,7 @@ func (c *conn) cmdMail(p *parser) { if size > c.maxMessageSize { // ../rfc/1870:136 ../rfc/3463:382 ecode := smtp.SeSys3MsgLimitExceeded4 - if size < defaultMaxMsgSize { + if size < config.DefaultMaxMsgSize { ecode = smtp.SeMailbox2MsgLimitExceeded3 } xsmtpUserErrorf(smtp.C552MailboxFull, ecode, "message too large") @@ -1507,7 +1506,7 @@ func (c *conn) cmdData(p *parser) { if errors.Is(err, errMessageTooLarge) { // ../rfc/1870:136 and ../rfc/3463:382 ecode := smtp.SeSys3MsgLimitExceeded4 - if n < defaultMaxMsgSize { + if n < config.DefaultMaxMsgSize { ecode = smtp.SeMailbox2MsgLimitExceeded3 } c.writecodeline(smtp.C451LocalErr, ecode, fmt.Sprintf("error copying data to file (%s)", mox.ReceivedID(c.cid)), err) @@ -1560,7 +1559,7 @@ func (c *conn) cmdData(p *parser) { if c.submission { // Hide internal hosts. // todo future: make this a config option, where admins specify ip ranges that they don't want exposed. also see ../rfc/5321:4321 - recvFrom = messageHeaderCommentDomain(mox.Conf.Static.HostnameDomain, c.smtputf8) + recvFrom = message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, c.smtputf8) } else { if len(c.hello.IP) > 0 { recvFrom = smtp.AddressLiteral(c.hello.IP) @@ -1595,7 +1594,7 @@ func (c *conn) cmdData(p *parser) { } } recvBy := mox.Conf.Static.HostnameDomain.XName(c.smtputf8) - recvBy += " (" + smtp.AddressLiteral(c.localIP) + ")" + recvBy += " (" + smtp.AddressLiteral(c.localIP) + ")" // todo: hide ip if internal? if c.smtputf8 && mox.Conf.Static.HostnameDomain.Unicode != "" { // This syntax is part of "VIA". recvBy += " (" + mox.Conf.Static.HostnameDomain.ASCII + ")" @@ -1624,7 +1623,11 @@ func (c *conn) cmdData(p *parser) { // For additional Received-header clauses, see: // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "via", "tcp", "with", with, "id", mox.ReceivedID(c.cid)) // ../rfc/5321:3158 - recvHdr.Add(" ", c.tlsReceivedComment()...) + if c.tls { + tlsConn := c.conn.(*tls.Conn) + tlsComment := message.TLSReceivedComment(c.log, tlsConn.ConnectionState()) + recvHdr.Add(" ", tlsComment...) + } recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z)) return recvHdr.String() } @@ -1639,19 +1642,10 @@ func (c *conn) cmdData(p *parser) { } } -// returns domain name optionally followed by message header comment with ascii-only name. -// The comment is only present when smtputf8 is true and the domain name is unicode. -// Caller should make sure the comment is allowed in the syntax. E.g. for Received, it is often allowed before the next field, so make sure such a next field is present. -func messageHeaderCommentDomain(domain dns.Domain, smtputf8 bool) string { - s := domain.XName(smtputf8) - if smtputf8 && domain.Unicode != "" { - s += " (" + domain.ASCII + ")" - } - return s -} - // submit is used for mail from authenticated users that we will try to deliver. func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, pdataFile **os.File) { + // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\( + dataFile := *pdataFile var msgPrefix []byte @@ -1696,66 +1690,20 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr msgPrefix = append(msgPrefix, "Date: "+time.Now().Format(message.RFC5322Z)+"\r\n"...) } - // Limit damage to the internet and our reputation in case of account compromise by - // limiting the max number of messages sent in a 24 hour window, both total number - // of messages and number of first-time recipients. + // Check outoging message rate limit. err = c.account.DB.Read(ctx, func(tx *bstore.Tx) error { - conf, _ := c.account.Conf() - msgmax := conf.MaxOutgoingMessagesPerDay - if msgmax == 0 { - // For human senders, 1000 recipients in a day is quite a lot. - msgmax = 1000 + rcpts := make([]smtp.Path, len(c.recipients)) + for i, r := range c.recipients { + rcpts[i] = r.rcptTo } - rcptmax := conf.MaxFirstTimeRecipientsPerDay - if rcptmax == 0 { - // Human senders may address a new human-sized list of people once in a while. In - // case of a compromise, a spammer will probably try to send to many new addresses. - rcptmax = 200 - } - - rcpts := map[string]time.Time{} - n := 0 - err := bstore.QueryTx[store.Outgoing](tx).FilterGreater("Submitted", time.Now().Add(-24*time.Hour)).ForEach(func(o store.Outgoing) error { - n++ - if rcpts[o.Recipient].IsZero() || o.Submitted.Before(rcpts[o.Recipient]) { - rcpts[o.Recipient] = o.Submitted - } - return nil - }) - xcheckf(err, "querying message recipients in past 24h") - if n+len(c.recipients) > msgmax { + msglimit, rcptlimit, err := c.account.SendLimitReached(tx, rcpts) + xcheckf(err, "checking sender limit") + if msglimit >= 0 { metricSubmission.WithLabelValues("messagelimiterror").Inc() - xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of messages (%d) over past 24h reached, try increasing per-account setting MaxOutgoingMessagesPerDay", msgmax) - } - - // Only check if max first-time recipients is reached if there are enough messages - // to trigger the limit. - if n+len(c.recipients) < rcptmax { - return nil - } - - isFirstTime := func(rcpt string, before time.Time) bool { - exists, err := bstore.QueryTx[store.Outgoing](tx).FilterNonzero(store.Outgoing{Recipient: rcpt}).FilterLess("Submitted", before).Exists() - xcheckf(err, "checking in database whether recipient is first-time") - return !exists - } - - firsttime := 0 - now := time.Now() - for _, rcptAcc := range c.recipients { - r := rcptAcc.rcptTo - if isFirstTime(r.XString(true), now) { - firsttime++ - } - } - for r, t := range rcpts { - if isFirstTime(r, t) { - firsttime++ - } - } - if firsttime > rcptmax { + xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of messages (%d) over past 24h reached, try increasing per-account setting MaxOutgoingMessagesPerDay", msglimit) + } else if rcptlimit >= 0 { metricSubmission.WithLabelValues("recipientlimiterror").Inc() - xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of new/first-time recipients (%d) over past 24h reached, try increasing per-account setting MaxFirstTimeRecipientsPerDay", rcptmax) + xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of new/first-time recipients (%d) over past 24h reached, try increasing per-account setting MaxFirstTimeRecipientsPerDay", rcptlimit) } return nil }) @@ -1782,15 +1730,15 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr } } - authResults := AuthResults{ + authResults := message.AuthResults{ Hostname: mox.Conf.Static.HostnameDomain.XName(c.smtputf8), Comment: mox.Conf.Static.HostnameDomain.ASCIIExtra(c.smtputf8), - Methods: []AuthMethod{ + Methods: []message.AuthMethod{ { Method: "auth", Result: "pass", - Props: []AuthProp{ - {"smtp", "mailfrom", c.mailFrom.XString(c.smtputf8), true, c.mailFrom.ASCIIExtra(c.smtputf8)}, + Props: []message.AuthProp{ + message.MakeAuthProp("smtp", "mailfrom", c.mailFrom.XString(c.smtputf8), true, c.mailFrom.ASCIIExtra(c.smtputf8)), }, }, }, @@ -1971,18 +1919,18 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW } // We'll be building up an Authentication-Results header. - authResults := AuthResults{ + authResults := message.AuthResults{ Hostname: mox.Conf.Static.HostnameDomain.XName(c.smtputf8), } // Reverse IP lookup results. // todo future: how useful is this? // ../rfc/5321:2481 - authResults.Methods = append(authResults.Methods, AuthMethod{ + authResults.Methods = append(authResults.Methods, message.AuthMethod{ Method: "iprev", Result: string(iprevStatus), - Props: []AuthProp{ - {"policy", "iprev", c.remoteIP.String(), false, ""}, + Props: []message.AuthProp{ + message.MakeAuthProp("policy", "iprev", c.remoteIP.String(), false, ""), }, }) @@ -2071,8 +2019,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW } // Add DKIM results to Authentication-Results header. - authResAddDKIM := func(result, comment, reason string, props []AuthProp) { - dm := AuthMethod{ + authResAddDKIM := func(result, comment, reason string, props []message.AuthProp) { + dm := message.AuthMethod{ Method: "dkim", Result: result, Comment: comment, @@ -2092,7 +2040,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW var domain, selector dns.Domain var identity *dkim.Identity var comment string - var props []AuthProp + var props []message.AuthProp if r.Sig != nil { // todo future: also specify whether dns record was dnssec-signed. if r.Record != nil && r.Record.PublicKey != nil { @@ -2103,16 +2051,16 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW sig := base64.StdEncoding.EncodeToString(r.Sig.Signature) sig = sig[:12] // Must be at least 8 characters and unique among the signatures. - props = []AuthProp{ - {"header", "d", r.Sig.Domain.XName(c.smtputf8), true, r.Sig.Domain.ASCIIExtra(c.smtputf8)}, - {"header", "s", r.Sig.Selector.XName(c.smtputf8), true, r.Sig.Selector.ASCIIExtra(c.smtputf8)}, - {"header", "a", r.Sig.Algorithm(), false, ""}, - {"header", "b", sig, false, ""}, // ../rfc/6008:147 + props = []message.AuthProp{ + message.MakeAuthProp("header", "d", r.Sig.Domain.XName(c.smtputf8), true, r.Sig.Domain.ASCIIExtra(c.smtputf8)), + message.MakeAuthProp("header", "s", r.Sig.Selector.XName(c.smtputf8), true, r.Sig.Selector.ASCIIExtra(c.smtputf8)), + message.MakeAuthProp("header", "a", r.Sig.Algorithm(), false, ""), + message.MakeAuthProp("header", "b", sig, false, ""), // ../rfc/6008:147 } domain = r.Sig.Domain selector = r.Sig.Selector if r.Sig.Identity != nil { - props = append(props, AuthProp{"header", "i", r.Sig.Identity.String(), true, ""}) + props = append(props, message.MakeAuthProp("header", "i", r.Sig.Identity.String(), true, "")) identity = r.Sig.Identity } } @@ -2138,11 +2086,11 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW spfIdentity = &spfArgs.MailFromDomain mailFromValidation = store.SPFValidation(receivedSPF.Result) } - var props []AuthProp + var props []message.AuthProp if spfIdentity != nil { - props = []AuthProp{{"smtp", string(receivedSPF.Identity), spfIdentity.XName(c.smtputf8), true, spfIdentity.ASCIIExtra(c.smtputf8)}} + props = []message.AuthProp{message.MakeAuthProp("smtp", string(receivedSPF.Identity), spfIdentity.XName(c.smtputf8), true, spfIdentity.ASCIIExtra(c.smtputf8))} } - authResults.Methods = append(authResults.Methods, AuthMethod{ + authResults.Methods = append(authResults.Methods, message.AuthMethod{ Method: "spf", Result: string(receivedSPF.Result), Props: props, @@ -2184,11 +2132,11 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW var dmarcUse bool var dmarcResult dmarc.Result const applyRandomPercentage = true - var dmarcMethod AuthMethod + var dmarcMethod message.AuthMethod var msgFromValidation = store.ValidationNone if msgFrom.IsZero() { dmarcResult.Status = dmarc.StatusNone - dmarcMethod = AuthMethod{ + dmarcMethod = message.AuthMethod{ Method: "dmarc", Result: string(dmarcResult.Status), } @@ -2199,12 +2147,12 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW defer dmarccancel() dmarcUse, dmarcResult = dmarc.Verify(dmarcctx, c.resolver, msgFrom.Domain, dkimResults, receivedSPF.Result, spfIdentity, applyRandomPercentage) dmarccancel() - dmarcMethod = AuthMethod{ + dmarcMethod = message.AuthMethod{ Method: "dmarc", Result: string(dmarcResult.Status), - Props: []AuthProp{ + Props: []message.AuthProp{ // ../rfc/7489:1489 - {"header", "from", msgFrom.Domain.ASCII, true, msgFrom.Domain.ASCIIExtra(c.smtputf8)}, + message.MakeAuthProp("header", "from", msgFrom.Domain.ASCII, true, msgFrom.Domain.ASCIIExtra(c.smtputf8)), }, } @@ -2723,47 +2671,3 @@ func (c *conn) cmdQuit(p *parser) { c.writecodeline(smtp.C221Closing, smtp.SeOther00, "okay thanks bye", nil) panic(cleanClose) } - -// return tokens representing comment in Received header that documents the TLS connection. -func (c *conn) tlsReceivedComment() []string { - if !c.tls { - return nil - } - - // todo future: we could use the "tls" clause for the Received header as specified in ../rfc/8314:496. however, the text implies it is only for submission, not regular smtp. and it cannot specify the tls version. for now, not worth the trouble. - - // Comments from other mail servers: - // gmail.com: (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128) - // yahoo.com: (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256) - // proton.me: (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256) (No client certificate requested) - // outlook.com: (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) - - var l []string - add := func(s string) { - l = append(l, s) - } - - versions := map[uint16]string{ - tls.VersionTLS10: "TLS1.0", - tls.VersionTLS11: "TLS1.1", - tls.VersionTLS12: "TLS1.2", - tls.VersionTLS13: "TLS1.3", - } - - tlsc := c.conn.(*tls.Conn) - st := tlsc.ConnectionState() - if version, ok := versions[st.Version]; ok { - add(version) - } else { - c.log.Info("unknown tls version identifier", mlog.Field("version", st.Version)) - add(fmt.Sprintf("TLS identifier %x", st.Version)) - } - - add(tls.CipherSuiteName(st.CipherSuite)) - - // Make it a comment. - l[0] = "(" + l[0] - l[len(l)-1] = l[len(l)-1] + ")" - - return l -} diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index d8349f9..6a8249c 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -111,10 +111,15 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test } func (ts *testserver) close() { + if ts.acc == nil { + return + } ts.comm.Unregister() queue.Shutdown() close(ts.switchDone) - ts.acc.Close() + err := ts.acc.Close() + tcheck(ts.t, err, "closing account") + ts.acc = nil } func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) { diff --git a/store/account.go b/store/account.go index 22a759c..582c162 100644 --- a/store/account.go +++ b/store/account.go @@ -34,6 +34,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -50,11 +51,18 @@ 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/publicsuffix" "github.com/mjl-/mox/scram" "github.com/mjl-/mox/smtp" ) +// If true, each time an account is closed its database file is checked for +// consistency. If an inconsistency is found, panic is called. Set by default +// because of all the packages with tests, the mox main function sets it to +// false again. +var CheckConsistencyOnClose = true + var xlog = mlog.New("store") var ( @@ -184,18 +192,101 @@ type Mailbox struct { // delivered to a mailbox. UIDNext UID - // Special-use hints. The mailbox holds these types of messages. Used - // in IMAP LIST (mailboxes) response. + SpecialUse + + // Keywords as used in messages. Storing a non-system keyword for a message + // automatically adds it to this list. Used in the IMAP FLAGS response. Only + // "atoms" are allowed (IMAP syntax), keywords are case-insensitive, only stored in + // lower case (for JMAP), sorted. + Keywords []string + + HaveCounts bool // Whether MailboxCounts have been initialized. + MailboxCounts // Statistics about messages, kept up to date whenever a change happens. +} + +// MailboxCounts tracks statistics about messages for a mailbox. +type MailboxCounts struct { + Total int64 // Total number of messages, excluding \Deleted. For JMAP. + Deleted int64 // Number of messages with \Deleted flag. Used for IMAP message count that includes messages with \Deleted. + Unread int64 // Messages without \Seen, excluding those with \Deleted, for JMAP. + Unseen int64 // Messages without \Seen, including those with \Deleted, for IMAP. + Size int64 // Number of bytes for all messages. +} + +func (mc MailboxCounts) String() string { + return fmt.Sprintf("%d total, %d deleted, %d unread, %d unseen, size %d bytes", mc.Total, mc.Deleted, mc.Unread, mc.Unseen, mc.Size) +} + +// Add increases mailbox counts mc with those of delta. +func (mc *MailboxCounts) Add(delta MailboxCounts) { + mc.Total += delta.Total + mc.Deleted += delta.Deleted + mc.Unread += delta.Unread + mc.Unseen += delta.Unseen + mc.Size += delta.Size +} + +// Add decreases mailbox counts mc with those of delta. +func (mc *MailboxCounts) Sub(delta MailboxCounts) { + mc.Total -= delta.Total + mc.Deleted -= delta.Deleted + mc.Unread -= delta.Unread + mc.Unseen -= delta.Unseen + mc.Size -= delta.Size +} + +// SpecialUse identifies a specific role for a mailbox, used by clients to +// understand where messages should go. +type SpecialUse struct { Archive bool Draft bool Junk bool Sent bool Trash bool +} - // Keywords as used in messages. Storing a non-system keyword for a message - // automatically adds it to this list. Used in the IMAP FLAGS response. Only - // "atoms", stored in lower case. - Keywords []string +// CalculateCounts calculates the full current counts for messages in the mailbox. +func (mb *Mailbox) CalculateCounts(tx *bstore.Tx) (mc MailboxCounts, err error) { + q := bstore.QueryTx[Message](tx) + q.FilterNonzero(Message{MailboxID: mb.ID}) + q.FilterEqual("Expunged", false) + err = q.ForEach(func(m Message) error { + mc.Add(m.MailboxCounts()) + return nil + }) + return +} + +// ChangeSpecialUse returns a change for special-use flags, for broadcasting to +// other connections. +func (mb Mailbox) ChangeSpecialUse() ChangeMailboxSpecialUse { + return ChangeMailboxSpecialUse{mb.ID, mb.Name, mb.SpecialUse} +} + +// ChangeKeywords returns a change with new keywords for a mailbox (e.g. after +// setting a new keyword on a message in the mailbox), for broadcasting to other +// connections. +func (mb Mailbox) ChangeKeywords() ChangeMailboxKeywords { + return ChangeMailboxKeywords{mb.ID, mb.Name, mb.Keywords} +} + +// KeywordsChanged returns whether the keywords in a mailbox have changed. +func (mb Mailbox) KeywordsChanged(origmb Mailbox) bool { + if len(mb.Keywords) != len(origmb.Keywords) { + return true + } + // Keywords are stored sorted. + for i, kw := range mb.Keywords { + if origmb.Keywords[i] != kw { + return true + } + } + return false +} + +// CountsChange returns a change with mailbox counts. +func (mb Mailbox) ChangeCounts() ChangeMailboxCounts { + return ChangeMailboxCounts{mb.ID, mb.Name, mb.MailboxCounts} } // Subscriptions are separate from existence of mailboxes. @@ -329,7 +420,10 @@ type Message struct { MessageHash []byte Flags - Keywords []string `bstore:"index"` // For keywords other than system flags or the basic well-known $-flags. Only in "atom" syntax, stored in lower case. + // For keywords other than system flags or the basic well-known $-flags. Only in + // "atom" syntax (IMAP), they are case-insensitive, always stored in lower-case + // (for JMAP), sorted. + Keywords []string `bstore:"index"` Size int64 TrainedJunk *bool // If nil, no training done yet. Otherwise, true is trained as junk, false trained as nonjunk. MsgPrefix []byte // Typically holds received headers and/or header separator. @@ -341,6 +435,36 @@ type Message struct { ParsedBuf []byte } +// MailboxCounts returns the delta to counts this message means for its +// mailbox. +func (m Message) MailboxCounts() (mc MailboxCounts) { + if m.Expunged { + return + } + if m.Deleted { + mc.Deleted++ + } else { + mc.Total++ + } + if !m.Seen { + mc.Unseen++ + if !m.Deleted { + mc.Unread++ + } + } + mc.Size += m.Size + return +} + +func (m Message) ChangeAddUID() ChangeAddUID { + return ChangeAddUID{m.MailboxID, m.UID, m.ModSeq, m.Flags, m.Keywords} +} + +func (m Message) ChangeFlags(orig Flags) ChangeFlags { + mask := m.Flags.Changed(orig) + return ChangeFlags{MailboxID: m.MailboxID, UID: m.UID, ModSeq: m.ModSeq, Mask: mask, Flags: m.Flags, Keywords: m.Keywords} +} + // ModSeq represents a modseq as stored in the database. ModSeq 0 in the // database is sent to the client as 1, because modseq 0 is special in IMAP. // ModSeq coming from the client are of type int64. @@ -433,12 +557,12 @@ func (m *Message) JunkFlagsForMailbox(mailbox string, conf config.Account) { } // Recipient represents the recipient of a message. It is tracked to allow -// first-time incoming replies from users this account has sent messages to. On -// IMAP append to Sent, the message is parsed and recipients are inserted as -// recipient. Recipients are never removed other than for removing the message. On -// IMAP move/copy, recipients aren't modified either. This assumes an IMAP client -// simply appends messages to the Sent mailbox (as opposed to copying messages from -// some place). +// first-time incoming replies from users this account has sent messages to. When a +// mailbox is added to the Sent mailbox the message is parsed and recipients are +// inserted as recipient. Recipients are never removed other than for removing the +// message. On move/copy of a message, recipients aren't modified either. For IMAP, +// this assumes a client simply appends messages to the Sent mailbox (as opposed to +// copying messages from some place). type Recipient struct { ID int64 MessageID int64 `bstore:"nonzero,ref Message"` // Ref gives it its own index, useful for fast removal as well. @@ -555,6 +679,27 @@ func openAccount(name string) (a *Account, rerr error) { if err := initAccount(db); err != nil { return nil, fmt.Errorf("initializing account: %v", err) } + } else { + // Ensure mailbox counts are set. + var mentioned bool + err := db.Write(context.TODO(), func(tx *bstore.Tx) error { + return bstore.QueryTx[Mailbox](tx).FilterEqual("HaveCounts", false).ForEach(func(mb Mailbox) error { + if !mentioned { + mentioned = true + xlog.Info("first calculation of mailbox counts for account", mlog.Field("account", name)) + } + mc, err := mb.CalculateCounts(tx) + if err != nil { + return err + } + mb.HaveCounts = true + mb.MailboxCounts = mc + return tx.Update(&mb) + }) + }) + if err != nil { + return nil, fmt.Errorf("calculating counts for mailbox: %v", err) + } } return &Account{ @@ -581,7 +726,7 @@ func initAccount(db *bstore.DB) error { } } for _, name := range mailboxes { - mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1} + mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1, HaveCounts: true} if strings.HasPrefix(name, "Archive") { mb.Archive = true } else if strings.HasPrefix(name, "Drafts") { @@ -613,9 +758,96 @@ func initAccount(db *bstore.DB) error { // Close reduces the reference count, and closes the database connection when // it was the last user. func (a *Account) Close() error { + if CheckConsistencyOnClose { + xerr := a.checkConsistency() + err := closeAccount(a) + if xerr != nil { + panic(xerr) + } + return err + } return closeAccount(a) } +// checkConsistency checks the consistency of the database and returns a non-nil +// error for these cases: +// +// - Missing HaveCounts. +// - Incorrect mailbox counts. +// - Message with UID >= mailbox uid next. +// - Mailbox uidvalidity >= account uid validity. +// - ModSeq > 0, CreateSeq > 0, CreateSeq <= ModSeq. +func (a *Account) checkConsistency() error { + var uiderrors []string // With a limit, could be many. + var modseqerrors []string // With limit. + var errors []string + + err := a.DB.Read(context.Background(), func(tx *bstore.Tx) error { + nuv := NextUIDValidity{ID: 1} + err := tx.Get(&nuv) + if err != nil { + return fmt.Errorf("fetching next uid validity: %v", err) + } + + mailboxes := map[int64]Mailbox{} + err = bstore.QueryTx[Mailbox](tx).ForEach(func(mb Mailbox) error { + mailboxes[mb.ID] = mb + + if mb.UIDValidity >= nuv.Next { + errmsg := fmt.Sprintf("mailbox %q (id %d) has uidvalidity %d >= account next uidvalidity %d", mb.Name, mb.ID, mb.UIDValidity, nuv.Next) + errors = append(errors, errmsg) + } + return nil + }) + if err != nil { + return fmt.Errorf("listing mailboxes: %v", err) + } + + counts := map[int64]MailboxCounts{} + err = bstore.QueryTx[Message](tx).ForEach(func(m Message) error { + mc := counts[m.MailboxID] + mc.Add(m.MailboxCounts()) + counts[m.MailboxID] = mc + + mb := mailboxes[m.MailboxID] + + if (m.ModSeq == 0 || m.CreateSeq == 0 || m.CreateSeq > m.ModSeq) && len(modseqerrors) < 20 { + modseqerr := fmt.Sprintf("message %d in mailbox %q (id %d) has invalid modseq %d or createseq %d, both must be > 0 and createseq <= modseq", m.ID, mb.Name, mb.ID, m.ModSeq, m.CreateSeq) + modseqerrors = append(modseqerrors, modseqerr) + } + if m.UID >= mb.UIDNext && len(uiderrors) < 20 { + uiderr := fmt.Sprintf("message %d in mailbox %q (id %d) has uid %d >= mailbox uidnext %d", m.ID, mb.Name, mb.ID, m.UID, mb.UIDNext) + uiderrors = append(uiderrors, uiderr) + } + return nil + }) + if err != nil { + return fmt.Errorf("reading messages: %v", err) + } + + for _, mb := range mailboxes { + if !mb.HaveCounts { + errmsg := fmt.Sprintf("mailbox %q (id %d) does not have counts, should be %#v", mb.Name, mb.ID, counts[mb.ID]) + errors = append(errors, errmsg) + } else if mb.MailboxCounts != counts[mb.ID] { + mbcounterr := fmt.Sprintf("mailbox %q (id %d) has wrong counts %s, should be %s", mb.Name, mb.ID, mb.MailboxCounts, counts[mb.ID]) + errors = append(errors, mbcounterr) + } + } + + return nil + }) + if err != nil { + return err + } + errors = append(errors, uiderrors...) + errors = append(errors, modseqerrors...) + if len(errors) > 0 { + return fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return nil +} + // Conf returns the configuration for this account if it still exists. During // an SMTP session, a configuration update may drop an account. func (a *Account) Conf() (config.Account, bool) { @@ -697,6 +929,8 @@ func (a *Account) WithRLock(fn func()) { // Must be called with account rlock or wlock. // // Caller must broadcast new message. +// +// Caller must update mailbox counts. func (a *Account) DeliverMessage(log *mlog.Log, tx *bstore.Tx, m *Message, msgFile *os.File, consumeFile, isSent, sync, notrain bool) error { if m.Expunged { return fmt.Errorf("cannot deliver expunged message") @@ -748,6 +982,7 @@ func (a *Account) DeliverMessage(log *mlog.Log, tx *bstore.Tx, m *Message, msgFi return fmt.Errorf("inserting message: %w", err) } + // todo: perhaps we should match the recipients based on smtp submission and a matching message-id? we now miss the addresses in bcc's. for webmail, we could insert the recipients directly. if isSent { // Attempt to parse the message for its To/Cc/Bcc headers, which we insert into Recipient. if part == nil { @@ -962,13 +1197,14 @@ func (a *Account) MailboxEnsure(tx *bstore.Tx, name string, subscribe bool) (mb Name: p, UIDValidity: uidval, UIDNext: 1, + HaveCounts: true, } err = tx.Insert(&mb) if err != nil { return Mailbox{}, nil, fmt.Errorf("creating new mailbox: %v", err) } - change := ChangeAddMailbox{Name: p} + var flags []string if subscribe { if tx.Get(&Subscription{p}) != nil { err := tx.Insert(&Subscription{p}) @@ -976,9 +1212,9 @@ func (a *Account) MailboxEnsure(tx *bstore.Tx, name string, subscribe bool) (mb return Mailbox{}, nil, fmt.Errorf("subscribing to mailbox: %v", err) } } - change.Flags = []string{`\Subscribed`} + flags = []string{`\Subscribed`} } - changes = append(changes, change) + changes = append(changes, ChangeAddMailbox{mb, flags}) } return mb, changes, nil } @@ -1019,14 +1255,13 @@ func (a *Account) SubscriptionEnsure(tx *bstore.Tx, name string) ([]Change, erro q := bstore.QueryTx[Mailbox](tx) q.FilterEqual("Name", name) - exists, err := q.Exists() - if err != nil { + _, err := q.Get() + if err == nil { + return []Change{ChangeAddSubscription{name, nil}}, nil + } else if err != bstore.ErrAbsent { return nil, fmt.Errorf("looking up mailbox for subscription: %w", err) } - if exists { - return []Change{ChangeAddSubscription{name}}, nil - } - return []Change{ChangeAddMailbox{Name: name, Flags: []string{`\Subscribed`, `\NonExistent`}}}, nil + return []Change{ChangeAddSubscription{name, []string{`\NonExistent`}}}, nil } // MessageRuleset returns the first ruleset (if any) that message the message @@ -1117,7 +1352,8 @@ func (a *Account) MessageReader(m Message) *MsgReader { // Deliver delivers an email to dest, based on the configured rulesets. // // Caller must hold account wlock (mailbox may be created). -// Message delivery and possible mailbox creation are broadcasted. +// Message delivery, possible mailbox creation, and updated mailbox counts are +// broadcasted. func (a *Account) Deliver(log *mlog.Log, dest config.Destination, m *Message, msgFile *os.File, consumeFile bool) error { var mailbox string rs := MessageRuleset(log, dest, m, m.MsgPrefix, msgFile) @@ -1134,7 +1370,8 @@ func (a *Account) Deliver(log *mlog.Log, dest config.Destination, m *Message, ms // DeliverMailbox delivers an email to the specified mailbox. // // Caller must hold account wlock (mailbox may be created). -// Message delivery and possible mailbox creation are broadcasted. +// Message delivery, possible mailbox creation, and updated mailbox counts are +// broadcasted. func (a *Account) DeliverMailbox(log *mlog.Log, mailbox string, m *Message, msgFile *os.File, consumeFile bool) error { var changes []Change err := a.DB.Write(context.TODO(), func(tx *bstore.Tx) error { @@ -1144,16 +1381,27 @@ func (a *Account) DeliverMailbox(log *mlog.Log, mailbox string, m *Message, msgF } m.MailboxID = mb.ID m.MailboxOrigID = mb.ID - changes = append(changes, chl...) - return a.DeliverMessage(log, tx, m, msgFile, consumeFile, mb.Sent, true, false) + // Update count early, DeliverMessage will update mb too and we don't want to fetch + // it again before updating. + mb.MailboxCounts.Add(m.MailboxCounts()) + if err := tx.Update(&mb); err != nil { + return fmt.Errorf("updating mailbox for delivery: %w", err) + } + + if err := a.DeliverMessage(log, tx, m, msgFile, consumeFile, mb.Sent, true, false); err != nil { + return err + } + + changes = append(changes, chl...) + changes = append(changes, m.ChangeAddUID(), mb.ChangeCounts()) + return nil }) // todo: if rename succeeded but transaction failed, we should remove the file. if err != nil { return err } - changes = append(changes, ChangeAddUID{m.MailboxID, m.UID, m.ModSeq, m.Flags, m.Keywords}) BroadcastChanges(a, changes) return nil } @@ -1189,13 +1437,14 @@ func (a *Account) TidyRejectsMailbox(log *mlog.Log, rejectsMailbox string) (hasS old := time.Now().Add(-14 * 24 * time.Hour) qdel := bstore.QueryTx[Message](tx) qdel.FilterNonzero(Message{MailboxID: mb.ID}) + qdel.FilterEqual("Expunged", false) qdel.FilterLess("Received", old) remove, err = qdel.List() if err != nil { return fmt.Errorf("listing old messages: %w", err) } - changes, err = a.removeMessages(context.TODO(), log, tx, mb, remove) + changes, err = a.rejectsRemoveMessages(context.TODO(), log, tx, mb, remove) if err != nil { return fmt.Errorf("removing messages: %w", err) } @@ -1203,6 +1452,7 @@ func (a *Account) TidyRejectsMailbox(log *mlog.Log, rejectsMailbox string) (hasS // We allow up to n messages. qcount := bstore.QueryTx[Message](tx) qcount.FilterNonzero(Message{MailboxID: mb.ID}) + qcount.FilterEqual("Expunged", false) qcount.Limit(1000) n, err := qcount.Count() if err != nil { @@ -1222,7 +1472,7 @@ func (a *Account) TidyRejectsMailbox(log *mlog.Log, rejectsMailbox string) (hasS return hasSpace, nil } -func (a *Account) removeMessages(ctx context.Context, log *mlog.Log, tx *bstore.Tx, mb *Mailbox, l []Message) ([]Change, error) { +func (a *Account) rejectsRemoveMessages(ctx context.Context, log *mlog.Log, tx *bstore.Tx, mb *Mailbox, l []Message) ([]Change, error) { if len(l) == 0 { return nil, nil } @@ -1247,7 +1497,7 @@ func (a *Account) removeMessages(ctx context.Context, log *mlog.Log, tx *bstore. return nil, fmt.Errorf("assign next modseq: %w", err) } - // Actually remove the messages. + // Expunge the messages. qx := bstore.QueryTx[Message](tx) qx.FilterIDs(ids) var expunged []Message @@ -1256,6 +1506,14 @@ func (a *Account) removeMessages(ctx context.Context, log *mlog.Log, tx *bstore. return nil, fmt.Errorf("expunging messages: %w", err) } + for _, m := range expunged { + m.Expunged = false // Was set by update, but would cause wrong count. + mb.MailboxCounts.Sub(m.MailboxCounts()) + } + if err := tx.Update(mb); err != nil { + return nil, fmt.Errorf("updating mailbox counts: %w", err) + } + // Mark as neutral and train so junk filter gets untrained with these (junk) messages. for i := range expunged { expunged[i].Junk = false @@ -1265,10 +1523,11 @@ func (a *Account) removeMessages(ctx context.Context, log *mlog.Log, tx *bstore. return nil, fmt.Errorf("retraining expunged messages: %w", err) } - changes := make([]Change, len(l)) + changes := make([]Change, len(l), len(l)+1) for i, m := range l { changes[i] = ChangeRemoveUIDs{mb.ID, []UID{m.UID}, modseq} } + changes = append(changes, mb.ChangeCounts()) return changes, nil } @@ -1298,12 +1557,13 @@ func (a *Account) RejectsRemove(log *mlog.Log, rejectsMailbox, messageID string) q := bstore.QueryTx[Message](tx) q.FilterNonzero(Message{MailboxID: mb.ID, MessageID: messageID}) + q.FilterEqual("Expunged", false) remove, err = q.List() if err != nil { return fmt.Errorf("listing messages to remove: %w", err) } - changes, err = a.removeMessages(context.TODO(), log, tx, mb, remove) + changes, err = a.rejectsRemoveMessages(context.TODO(), log, tx, mb, remove) if err != nil { return fmt.Errorf("removing messages: %w", err) } @@ -1447,44 +1707,455 @@ func (f Flags) Set(mask, flags Flags) Flags { return r } -// RemoveKeywords removes keywords from l, modifying and returning it. Should only -// be used with lower-case keywords, not with system flags like \Seen. -func RemoveKeywords(l, remove []string) []string { - for _, k := range remove { - if i := slices.Index(l, k); i >= 0 { - copy(l[i:], l[i+1:]) - l = l[:len(l)-1] - } - } - return l +// Changed returns a mask of flags that have been between f and other. +func (f Flags) Changed(other Flags) (mask Flags) { + mask.Seen = f.Seen != other.Seen + mask.Answered = f.Answered != other.Answered + mask.Flagged = f.Flagged != other.Flagged + mask.Forwarded = f.Forwarded != other.Forwarded + mask.Junk = f.Junk != other.Junk + mask.Notjunk = f.Notjunk != other.Notjunk + mask.Deleted = f.Deleted != other.Deleted + mask.Draft = f.Draft != other.Draft + mask.Phishing = f.Phishing != other.Phishing + mask.MDNSent = f.MDNSent != other.MDNSent + return } -// MergeKeywords adds keywords from add into l, updating and returning it along -// with whether it added any keyword. Keywords are only added if they aren't -// already present. Should only be used with lower-case keywords, not with system -// flags like \Seen. -func MergeKeywords(l, add []string) ([]string, bool) { +var systemWellKnownFlags = map[string]bool{ + `\answered`: true, + `\flagged`: true, + `\deleted`: true, + `\seen`: true, + `\draft`: true, + `$junk`: true, + `$notjunk`: true, + `$forwarded`: true, + `$phishing`: true, + `$mdnsent`: true, +} + +// ParseFlagsKeywords parses a list of textual flags into system/known flags, and +// other keywords. Keywords are lower-cased and sorted and check for valid syntax. +func ParseFlagsKeywords(l []string) (flags Flags, keywords []string, rerr error) { + fields := map[string]*bool{ + `\answered`: &flags.Answered, + `\flagged`: &flags.Flagged, + `\deleted`: &flags.Deleted, + `\seen`: &flags.Seen, + `\draft`: &flags.Draft, + `$junk`: &flags.Junk, + `$notjunk`: &flags.Notjunk, + `$forwarded`: &flags.Forwarded, + `$phishing`: &flags.Phishing, + `$mdnsent`: &flags.MDNSent, + } + seen := map[string]bool{} + for _, f := range l { + f = strings.ToLower(f) + if field, ok := fields[f]; ok { + *field = true + } else if seen[f] { + if moxvar.Pedantic { + return Flags{}, nil, fmt.Errorf("duplicate keyword %s", f) + } + } else { + if err := CheckKeyword(f); err != nil { + return Flags{}, nil, fmt.Errorf("invalid keyword %s", f) + } + keywords = append(keywords, f) + seen[f] = true + } + } + sort.Strings(keywords) + return flags, keywords, nil +} + +// RemoveKeywords removes keywords from l, returning whether any modifications were +// made, and a slice, a new slice in case of modifications. Keywords must have been +// validated earlier, e.g. through ParseFlagKeywords or CheckKeyword. Should only +// be used with valid keywords, not with system flags like \Seen. +func RemoveKeywords(l, remove []string) ([]string, bool) { + var copied bool var changed bool - for _, k := range add { - if !slices.Contains(l, k) { - l = append(l, k) + for _, k := range remove { + if i := slices.Index(l, k); i >= 0 { + if !copied { + l = append([]string{}, l...) + copied = true + } + copy(l[i:], l[i+1:]) + l = l[:len(l)-1] changed = true } } return l, changed } -// ValidLowercaseKeyword returns whether s is a valid, lower-case, keyword. -func ValidLowercaseKeyword(s string) bool { - for _, c := range s { - if c >= 'a' && c <= 'z' { - continue - } - // ../rfc/9051:6334 - const atomspecials = `(){%*"\]` - if c <= ' ' || c > 0x7e || strings.ContainsRune(atomspecials, c) { - return false +// MergeKeywords adds keywords from add into l, returning whether it added any +// keyword, and the slice with keywords, a new slice if modifications were made. +// Keywords are only added if they aren't already present. Should only be used with +// keywords, not with system flags like \Seen. +func MergeKeywords(l, add []string) ([]string, bool) { + var copied bool + var changed bool + for _, k := range add { + if !slices.Contains(l, k) { + if !copied { + l = append([]string{}, l...) + copied = true + } + l = append(l, k) + changed = true } } - return len(s) > 0 + if changed { + sort.Strings(l) + } + return l, changed +} + +// CheckKeyword returns an error if kw is not a valid keyword. Kw should +// already be in lower-case. +func CheckKeyword(kw string) error { + if kw == "" { + return fmt.Errorf("keyword cannot be empty") + } + if systemWellKnownFlags[kw] { + return fmt.Errorf("cannot use well-known flag as keyword") + } + for _, c := range kw { + // ../rfc/9051:6334 + if c <= ' ' || c > 0x7e || c >= 'A' && c <= 'Z' || strings.ContainsRune(`(){%*"\]`, c) { + return errors.New(`not a valid keyword, must be lower-case ascii without spaces and without any of these characters: (){%*"\]`) + } + } + return nil +} + +// SendLimitReached checks whether sending a message to recipients would reach +// the limit of outgoing messages for the account. If so, the message should +// not be sent. If the returned numbers are >= 0, the limit was reached and the +// values are the configured limits. +// +// To limit damage to the internet and our reputation in case of account +// compromise, we limit the max number of messages sent in a 24 hour window, both +// total number of messages and number of first-time recipients. +func (a *Account) SendLimitReached(tx *bstore.Tx, recipients []smtp.Path) (msglimit, rcptlimit int, rerr error) { + conf, _ := a.Conf() + msgmax := conf.MaxOutgoingMessagesPerDay + if msgmax == 0 { + // For human senders, 1000 recipients in a day is quite a lot. + msgmax = 1000 + } + rcptmax := conf.MaxFirstTimeRecipientsPerDay + if rcptmax == 0 { + // Human senders may address a new human-sized list of people once in a while. In + // case of a compromise, a spammer will probably try to send to many new addresses. + rcptmax = 200 + } + + rcpts := map[string]time.Time{} + n := 0 + err := bstore.QueryTx[Outgoing](tx).FilterGreater("Submitted", time.Now().Add(-24*time.Hour)).ForEach(func(o Outgoing) error { + n++ + if rcpts[o.Recipient].IsZero() || o.Submitted.Before(rcpts[o.Recipient]) { + rcpts[o.Recipient] = o.Submitted + } + return nil + }) + if err != nil { + return -1, -1, fmt.Errorf("querying message recipients in past 24h: %w", err) + } + if n+len(recipients) > msgmax { + return msgmax, -1, nil + } + + // Only check if max first-time recipients is reached if there are enough messages + // to trigger the limit. + if n+len(recipients) < rcptmax { + return -1, -1, nil + } + + isFirstTime := func(rcpt string, before time.Time) (bool, error) { + exists, err := bstore.QueryTx[Outgoing](tx).FilterNonzero(Outgoing{Recipient: rcpt}).FilterLess("Submitted", before).Exists() + return !exists, err + } + + firsttime := 0 + now := time.Now() + for _, r := range recipients { + if first, err := isFirstTime(r.XString(true), now); err != nil { + return -1, -1, fmt.Errorf("checking whether recipient is first-time: %v", err) + } else if first { + firsttime++ + } + } + for r, t := range rcpts { + if first, err := isFirstTime(r, t); err != nil { + return -1, -1, fmt.Errorf("checking whether recipient is first-time: %v", err) + } else if first { + firsttime++ + } + } + if firsttime > rcptmax { + return -1, rcptmax, nil + } + return -1, -1, nil +} + +// MailboxCreate creates a new mailbox, including any missing parent mailboxes, +// the total list of created mailboxes is returned in created. On success, if +// exists is false and rerr nil, the changes must be broadcasted by the caller. +// +// Name must be in normalized form. +func (a *Account) MailboxCreate(tx *bstore.Tx, name string) (changes []Change, created []string, exists bool, rerr error) { + elems := strings.Split(name, "/") + var p string + for i, elem := range elems { + if i > 0 { + p += "/" + } + p += elem + exists, err := a.MailboxExists(tx, p) + if err != nil { + return nil, nil, false, fmt.Errorf("checking if mailbox exists") + } + if exists { + if i == len(elems)-1 { + return nil, nil, true, fmt.Errorf("mailbox already exists") + } + continue + } + _, nchanges, err := a.MailboxEnsure(tx, p, true) + if err != nil { + return nil, nil, false, fmt.Errorf("ensuring mailbox exists") + } + changes = append(changes, nchanges...) + created = append(created, p) + } + return changes, created, false, nil +} + +// MailboxRename renames mailbox mbsrc to dst, and any missing parents for the +// destination, and any children of mbsrc and the destination. +// +// Names must be normalized and cannot be Inbox. +func (a *Account) MailboxRename(tx *bstore.Tx, mbsrc Mailbox, dst string) (changes []Change, isInbox, notExists, alreadyExists bool, rerr error) { + if mbsrc.Name == "Inbox" || dst == "Inbox" { + return nil, true, false, false, fmt.Errorf("inbox cannot be renamed") + } + + // We gather existing mailboxes that we need for deciding what to create/delete/update. + q := bstore.QueryTx[Mailbox](tx) + srcPrefix := mbsrc.Name + "/" + dstRoot := strings.SplitN(dst, "/", 2)[0] + dstRootPrefix := dstRoot + "/" + q.FilterFn(func(mb Mailbox) bool { + return mb.Name == mbsrc.Name || strings.HasPrefix(mb.Name, srcPrefix) || mb.Name == dstRoot || strings.HasPrefix(mb.Name, dstRootPrefix) + }) + q.SortAsc("Name") // We'll rename the parents before children. + l, err := q.List() + if err != nil { + return nil, false, false, false, fmt.Errorf("listing relevant mailboxes: %v", err) + } + + mailboxes := map[string]Mailbox{} + for _, mb := range l { + mailboxes[mb.Name] = mb + } + + if _, ok := mailboxes[mbsrc.Name]; !ok { + return nil, false, true, false, fmt.Errorf("mailbox does not exist") + } + + uidval, err := a.NextUIDValidity(tx) + if err != nil { + return nil, false, false, false, fmt.Errorf("next uid validity: %v", err) + } + + // Ensure parent mailboxes for the destination paths exist. + var parent string + dstElems := strings.Split(dst, "/") + for i, elem := range dstElems[:len(dstElems)-1] { + if i > 0 { + parent += "/" + } + parent += elem + + mb, ok := mailboxes[parent] + if ok { + continue + } + omb := mb + mb = Mailbox{ + ID: omb.ID, + Name: parent, + UIDValidity: uidval, + UIDNext: 1, + HaveCounts: true, + } + if err := tx.Insert(&mb); err != nil { + return nil, false, false, false, fmt.Errorf("creating parent mailbox %q: %v", mb.Name, err) + } + if err := tx.Get(&Subscription{Name: parent}); err != nil { + if err := tx.Insert(&Subscription{Name: parent}); err != nil { + return nil, false, false, false, fmt.Errorf("creating subscription for %q: %v", parent, err) + } + } + changes = append(changes, ChangeAddMailbox{Mailbox: mb, Flags: []string{`\Subscribed`}}) + } + + // Process src mailboxes, renaming them to dst. + for _, srcmb := range l { + if srcmb.Name != mbsrc.Name && !strings.HasPrefix(srcmb.Name, srcPrefix) { + continue + } + srcName := srcmb.Name + dstName := dst + srcmb.Name[len(mbsrc.Name):] + if _, ok := mailboxes[dstName]; ok { + return nil, false, false, true, fmt.Errorf("destination mailbox %q already exists", dstName) + } + + srcmb.Name = dstName + srcmb.UIDValidity = uidval + if err := tx.Update(&srcmb); err != nil { + return nil, false, false, false, fmt.Errorf("renaming mailbox: %v", err) + } + + var dstFlags []string + if tx.Get(&Subscription{Name: dstName}) == nil { + dstFlags = []string{`\Subscribed`} + } + changes = append(changes, ChangeRenameMailbox{MailboxID: srcmb.ID, OldName: srcName, NewName: dstName, Flags: dstFlags}) + } + + // If we renamed e.g. a/b to a/b/c/d, and a/b/c to a/b/c/d/c, we'll have to recreate a/b and a/b/c. + srcElems := strings.Split(mbsrc.Name, "/") + xsrc := mbsrc.Name + for i := 0; i < len(dstElems) && strings.HasPrefix(dst, xsrc+"/"); i++ { + mb := Mailbox{ + UIDValidity: uidval, + UIDNext: 1, + Name: xsrc, + HaveCounts: true, + } + if err := tx.Insert(&mb); err != nil { + return nil, false, false, false, fmt.Errorf("creating mailbox at old path %q: %v", mb.Name, err) + } + xsrc += "/" + dstElems[len(srcElems)+i] + } + return changes, false, false, false, nil +} + +// MailboxDelete deletes a mailbox by ID. If it has children, the return value +// indicates that and an error is returned. +// +// Caller should broadcast the changes and remove files for the removed message IDs. +func (a *Account) MailboxDelete(ctx context.Context, log *mlog.Log, tx *bstore.Tx, mailbox Mailbox) (changes []Change, removeMessageIDs []int64, hasChildren bool, rerr error) { + // Look for existence of child mailboxes. There is a lot of text in the IMAP RFCs about + // NoInferior and NoSelect. We just require only leaf mailboxes are deleted. + qmb := bstore.QueryTx[Mailbox](tx) + mbprefix := mailbox.Name + "/" + qmb.FilterFn(func(mb Mailbox) bool { + return strings.HasPrefix(mb.Name, mbprefix) + }) + if childExists, err := qmb.Exists(); err != nil { + return nil, nil, false, fmt.Errorf("checking if mailbox has child: %v", err) + } else if childExists { + return nil, nil, true, fmt.Errorf("mailbox has a child, only leaf mailboxes can be deleted") + } + + // todo jmap: instead of completely deleting a mailbox and its messages, we need to mark them all as expunged. + + qm := bstore.QueryTx[Message](tx) + qm.FilterNonzero(Message{MailboxID: mailbox.ID}) + remove, err := qm.List() + if err != nil { + return nil, nil, false, fmt.Errorf("listing messages to remove: %v", err) + } + + if len(remove) > 0 { + removeIDs := make([]any, len(remove)) + for i, m := range remove { + removeIDs[i] = m.ID + } + qmr := bstore.QueryTx[Recipient](tx) + qmr.FilterEqual("MessageID", removeIDs...) + if _, err = qmr.Delete(); err != nil { + return nil, nil, false, fmt.Errorf("removing message recipients for messages: %v", err) + } + + qm = bstore.QueryTx[Message](tx) + qm.FilterNonzero(Message{MailboxID: mailbox.ID}) + if _, err := qm.Delete(); err != nil { + return nil, nil, false, fmt.Errorf("removing messages: %v", err) + } + + for _, m := range remove { + if !m.Expunged { + removeMessageIDs = append(removeMessageIDs, m.ID) + } + } + + // Mark messages as not needing training. Then retrain them, so they are untrained if they were. + n := 0 + o := 0 + for _, m := range remove { + if !m.Expunged { + remove[o] = m + remove[o].Junk = false + remove[o].Notjunk = false + n++ + } + } + remove = remove[:n] + if err := a.RetrainMessages(ctx, log, tx, remove, true); err != nil { + return nil, nil, false, fmt.Errorf("untraining deleted messages: %v", err) + } + } + + if err := tx.Delete(&Mailbox{ID: mailbox.ID}); err != nil { + return nil, nil, false, fmt.Errorf("removing mailbox: %v", err) + } + return []Change{ChangeRemoveMailbox{MailboxID: mailbox.ID, Name: mailbox.Name}}, removeMessageIDs, false, nil +} + +// CheckMailboxName checks if name is valid, returning an INBOX-normalized name. +// I.e. it changes various casings of INBOX and INBOX/* to Inbox and Inbox/*. +// Name is invalid if it contains leading/trailing/double slashes, or when it isn't +// unicode-normalized, or when empty or has special characters. +// +// If name is the inbox, and allowInbox is false, this is indicated with the isInbox return parameter. +// For that case, and for other invalid names, an error is returned. +func CheckMailboxName(name string, allowInbox bool) (normalizedName string, isInbox bool, rerr error) { + first := strings.SplitN(name, "/", 2)[0] + if strings.EqualFold(first, "inbox") { + if len(name) == len("inbox") && !allowInbox { + return "", true, fmt.Errorf("special mailbox name Inbox not allowed") + } + name = "Inbox" + name[len("Inbox"):] + } + + if norm.NFC.String(name) != name { + return "", false, errors.New("non-unicode-normalized mailbox names not allowed") + } + + if name == "" { + return "", false, errors.New("empty mailbox name") + } + if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "//") { + return "", false, errors.New("bad slashes in mailbox name") + } + for _, c := range name { + switch c { + case '%', '*', '#', '&': + return "", false, fmt.Errorf("character %c not allowed in mailbox name", c) + } + // ../rfc/6855:192 + if c <= 0x1f || c >= 0x7f && c <= 0x9f || c == 0x2028 || c == 0x2029 { + return "", false, errors.New("control characters not allowed in mailbox name") + } + } + return name, false, nil } diff --git a/store/account_test.go b/store/account_test.go index 7f7ca84..b96ba15 100644 --- a/store/account_test.go +++ b/store/account_test.go @@ -32,7 +32,10 @@ func TestMailbox(t *testing.T) { mox.MustLoadConfig(true, false) acc, err := OpenAccount("mjl") tcheck(t, err, "open account") - defer acc.Close() + defer func() { + err = acc.Close() + tcheck(t, err, "closing account") + }() switchDone := Switchboard() defer close(switchDone) @@ -57,7 +60,7 @@ func TestMailbox(t *testing.T) { } msent := m var mbsent Mailbox - mbrejects := Mailbox{Name: "Rejects", UIDValidity: 1, UIDNext: 1} + mbrejects := Mailbox{Name: "Rejects", UIDValidity: 1, UIDNext: 1, HaveCounts: true} mreject := m mconsumed := Message{ Received: m.Received, @@ -78,6 +81,12 @@ func TestMailbox(t *testing.T) { err = acc.DeliverMessage(xlog, tx, &msent, msgFile, false, true, true, false) tcheck(t, err, "deliver message") + err = tx.Get(&mbsent) + tcheck(t, err, "get mbsent") + mbsent.Add(msent.MailboxCounts()) + err = tx.Update(&mbsent) + tcheck(t, err, "update mbsent") + err = tx.Insert(&mbrejects) tcheck(t, err, "insert rejects mailbox") mreject.MailboxID = mbrejects.ID @@ -85,6 +94,12 @@ func TestMailbox(t *testing.T) { err = acc.DeliverMessage(xlog, tx, &mreject, msgFile, false, false, true, false) tcheck(t, err, "deliver message") + err = tx.Get(&mbrejects) + tcheck(t, err, "get mbrejects") + mbrejects.Add(mreject.MailboxCounts()) + err = tx.Update(&mbrejects) + tcheck(t, err, "update mbrejects") + return nil }) tcheck(t, err, "deliver as sent and rejects") diff --git a/store/import.go b/store/import.go index 3802d0c..ee30956 100644 --- a/store/import.go +++ b/store/import.go @@ -148,7 +148,7 @@ func (mr *MboxReader) Next() (*Message, *os.File, string, error) { case "mdnsent", "$mdnsent": flags.MDNSent = true default: - if ValidLowercaseKeyword(word) { + if err := CheckKeyword(word); err == nil { keywords[word] = true } } @@ -205,13 +205,13 @@ func (mr *MboxReader) Next() (*Message, *os.File, string, error) { } type MaildirReader struct { - createTemp func(pattern string) (*os.File, error) - newf, curf *os.File - f *os.File // File we are currently reading from. We first read newf, then curf. - dir string // Name of directory for f. Can be empty on first call. - entries []os.DirEntry - dovecotKeywords []string - log *mlog.Log + createTemp func(pattern string) (*os.File, error) + newf, curf *os.File + f *os.File // File we are currently reading from. We first read newf, then curf. + dir string // Name of directory for f. Can be empty on first call. + entries []os.DirEntry + dovecotFlags []string // Lower-case flags/keywords. + log *mlog.Log } func NewMaildirReader(createTemp func(pattern string) (*os.File, error), newf, curf *os.File, log *mlog.Log) *MaildirReader { @@ -226,7 +226,7 @@ func NewMaildirReader(createTemp func(pattern string) (*os.File, error), newf, c // Best-effort parsing of dovecot keywords. kf, err := os.Open(filepath.Join(filepath.Dir(newf.Name()), "dovecot-keywords")) if err == nil { - mr.dovecotKeywords, err = ParseDovecotKeywords(kf, log) + mr.dovecotFlags, err = ParseDovecotKeywordsFlags(kf, log) log.Check(err, "parsing dovecot keywords file") err = kf.Close() log.Check(err, "closing dovecot-keywords file") @@ -336,10 +336,10 @@ func (mr *MaildirReader) Next() (*Message, *os.File, string, error) { default: if c >= 'a' && c <= 'z' { index := int(c - 'a') - if index >= len(mr.dovecotKeywords) { + if index >= len(mr.dovecotFlags) { continue } - kw := strings.ToLower(mr.dovecotKeywords[index]) + kw := mr.dovecotFlags[index] switch kw { case "$forwarded", "forwarded": flags.Forwarded = true @@ -352,9 +352,7 @@ func (mr *MaildirReader) Next() (*Message, *os.File, string, error) { case "$phishing", "phishing": flags.Phishing = true default: - if ValidLowercaseKeyword(kw) { - keywords[kw] = true - } + keywords[kw] = true } } } @@ -370,7 +368,11 @@ func (mr *MaildirReader) Next() (*Message, *os.File, string, error) { return m, mf, p, nil } -func ParseDovecotKeywords(r io.Reader, log *mlog.Log) ([]string, error) { +// ParseDovecotKeywordsFlags attempts to parse a dovecot-keywords file. It only +// returns valid flags/keywords, as lower-case. If an error is encountered and +// returned, any keywords that were found are still returned. The returned list has +// both system/well-known flags and custom keywords. +func ParseDovecotKeywordsFlags(r io.Reader, log *mlog.Log) ([]string, error) { /* If the dovecot-keywords file is present, we parse its additional flags, see https://doc.dovecot.org/admin_manual/mailbox_formats/maildir/ @@ -406,7 +408,14 @@ func ParseDovecotKeywords(r io.Reader, log *mlog.Log) ([]string, error) { errs = append(errs, fmt.Sprintf("duplicate dovecot keyword: %q", s)) continue } - keywords[index] = t[1] + kw := strings.ToLower(t[1]) + if !systemWellKnownFlags[kw] { + if err := CheckKeyword(kw); err != nil { + errs = append(errs, fmt.Sprintf("invalid keyword %q", kw)) + continue + } + } + keywords[index] = kw if index >= end { end = index + 1 } diff --git a/store/import_test.go b/store/import_test.go index 9b93229..ae7fdb2 100644 --- a/store/import_test.go +++ b/store/import_test.go @@ -85,14 +85,14 @@ func TestParseDovecotKeywords(t *testing.T) { 3 $Forwarded 4 $Junk ` - keywords, err := ParseDovecotKeywords(strings.NewReader(data), mlog.New("dovecotkeywords")) + flags, err := ParseDovecotKeywordsFlags(strings.NewReader(data), mlog.New("dovecotkeywords")) if err != nil { t.Fatalf("parsing dovecot-keywords: %v", err) } - got := strings.Join(keywords, ",") - want := "Old,Junk,NonJunk,$Forwarded,$Junk" + got := strings.Join(flags, ",") + want := "old,junk,nonjunk,$forwarded,$junk" if got != want { - t.Fatalf("parsing dovecot keywords, got %q, want %q", got, want) + t.Fatalf("parsing dovecot keywords, got %q, expect %q", got, want) } } diff --git a/store/state.go b/store/state.go index eaa554e..c4b0d82 100644 --- a/store/state.go +++ b/store/state.go @@ -36,7 +36,7 @@ type ChangeAddUID struct { // ChangeRemoveUIDs is sent for removal of one or more messages from a mailbox. type ChangeRemoveUIDs struct { MailboxID int64 - UIDs []UID + UIDs []UID // Must be in increasing UID order, for IMAP. ModSeq ModSeq } @@ -47,30 +47,55 @@ type ChangeFlags struct { ModSeq ModSeq Mask Flags // Which flags are actually modified. Flags Flags // New flag values. All are set, not just mask. - Keywords []string // Other flags. + Keywords []string // Non-system/well-known flags/keywords/labels. } // ChangeRemoveMailbox is sent for a removed mailbox. type ChangeRemoveMailbox struct { - Name string + MailboxID int64 + Name string } // ChangeAddMailbox is sent for a newly created mailbox. type ChangeAddMailbox struct { - Name string - Flags []string + Mailbox Mailbox + Flags []string // For flags like \Subscribed. } // ChangeRenameMailbox is sent for a rename mailbox. type ChangeRenameMailbox struct { - OldName string - NewName string - Flags []string + MailboxID int64 + OldName string + NewName string + Flags []string } // ChangeAddSubscription is sent for an added subscription to a mailbox. type ChangeAddSubscription struct { - Name string + Name string + Flags []string // For additional IMAP flags like \NonExistent. +} + +// ChangeMailboxCounts is sent when the number of total/deleted/unseen/unread messages changes. +type ChangeMailboxCounts struct { + MailboxID int64 + MailboxName string + MailboxCounts +} + +// ChangeMailboxSpecialUse is sent when a special-use flag changes. +type ChangeMailboxSpecialUse struct { + MailboxID int64 + MailboxName string + SpecialUse SpecialUse +} + +// ChangeMailboxKeywords is sent when keywords are changed for a mailbox. For +// example, when a message is added with a previously unseen keyword. +type ChangeMailboxKeywords struct { + MailboxID int64 + MailboxName string + Keywords []string } var switchboardBusy atomic.Bool diff --git a/testdata/httpaccount/domains.conf b/testdata/httpaccount/domains.conf index 4eb5404..515602f 100644 --- a/testdata/httpaccount/domains.conf +++ b/testdata/httpaccount/domains.conf @@ -3,6 +3,7 @@ Domains: Accounts: mjl: Domain: mox.example + FullName: mjl Destinations: mjl@mox.example: Mailbox: Inbox diff --git a/testdata/integration/moxacmepebble.sh b/testdata/integration/moxacmepebble.sh index 8d6e773..16f102f 100755 --- a/testdata/integration/moxacmepebble.sh +++ b/testdata/integration/moxacmepebble.sh @@ -29,7 +29,7 @@ unbound-control -s 172.28.1.30 reload # reload unbound with zone file changes CURL_CA_BUNDLE=/integration/tls/ca.pem curl -o /integration/tmp-pebble-ca.pem https://acmepebble.example:15000/roots/0 -mox serve & +mox -checkconsistency serve & while true; do if test -e data/ctl; then echo -n accountpass1234 | mox setaccountpassword moxtest1@mox1.example diff --git a/testdata/integration/moxmail2.sh b/testdata/integration/moxmail2.sh index ec9cbd3..cae5590 100755 --- a/testdata/integration/moxmail2.sh +++ b/testdata/integration/moxmail2.sh @@ -25,7 +25,7 @@ EOF sed -n '/^;/,/IN CAA/p' output.txt >>/integration/example-integration.zone unbound-control -s 172.28.1.30 reload # reload unbound with zone file changes -mox serve & +mox -checkconsistency serve & while true; do if test -e data/ctl; then echo -n accountpass4321 | mox setaccountpassword moxtest2@mox2.example diff --git a/testdata/webmail/domains.conf b/testdata/webmail/domains.conf new file mode 100644 index 0000000..339e2c2 --- /dev/null +++ b/testdata/webmail/domains.conf @@ -0,0 +1,28 @@ +Domains: + mox.example: + DKIM: + Selectors: + testsel: + PrivateKeyFile: testsel.rsakey.pkcs8.pem + Sign: + - testsel +Accounts: + other: + Domain: mox.example + Destinations: + other@mox.example: nil + mjl: + MaxOutgoingMessagesPerDay: 30 + MaxFirstTimeRecipientsPerDay: 10 + Domain: mox.example + Destinations: + mjl@mox.example: nil + møx@mox.example: nil + RejectsMailbox: Rejects + JunkFilter: + Threshold: 0.95 + Params: + Twograms: true + MaxPower: 0.1 + TopWords: 10 + IgnoreWords: 0.1 diff --git a/testdata/webmail/mox.conf b/testdata/webmail/mox.conf new file mode 100644 index 0000000..1370e33 --- /dev/null +++ b/testdata/webmail/mox.conf @@ -0,0 +1,11 @@ +DataDir: data +User: 1000 +LogLevel: trace +Hostname: mox.example +Listeners: + local: + IPs: + - 0.0.0.0 +Postmaster: + Account: mjl + Mailbox: postmaster diff --git a/testdata/webmail/testsel.rsakey.pkcs8.pem b/testdata/webmail/testsel.rsakey.pkcs8.pem new file mode 100644 index 0000000..73d742c --- /dev/null +++ b/testdata/webmail/testsel.rsakey.pkcs8.pem @@ -0,0 +1,30 @@ +-----BEGIN PRIVATE KEY----- +Note: RSA private key for use with DKIM, generated by mox + +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdkh3fKzvRUWym +n9UwVrEw6s2Mc0+DTg04TWJKGKHXpvcTHuEcE6ALVS9MZKasyVsIHU7FNeS9/qNb +pLihhGdlhU3KAfrMpTBhiFpJoYiDXED98Of4iBxNHIuheLMxSBSClMbLGE2vAgha +/6LuONuzdMqk/c1TijBD+vGjCZI2qD58cgXWWKRK9e+WNhKNoVdedZ9iJtbtN0MI +UWk3iwHmjXf5qzS7i8vDoy86Ln0HW0vKl7UtwemLVv09/E23OdNN163eQvSlrEhx +a0odPQsM9SizxhiaI9rmcZtSqULt37hhPaNA+/AbELCzWijZPDqePVRqKGd5gYDK +8STLj0UHAgMBAAECggEBAKVkJJgplYUx2oCmXmSu0aVKIBTvHjNNV+DnIq9co7Ju +F5BWRILIw3ayJ5RGrYPc6e6ssdfT2uNX6GjIFGm8g9HsJ5zazXNk+zBSr9K2mUg0 +3O6xnPaP41BMNo5ZoqjuvSCcHagMhDBWvBXxLJXWK2lRjNKMAXCSfmTANQ8WXeYd +XG2nYTPtBu6UgY8W6sKAx1xetxBrzk8q6JTxb5eVG22BSiUniWYif+XVmAj1u6TH +0m6X0Kb6zsMYYgKPC2hmDsxD3uZ7qBNxxJzzLjpK6eP9aeFKzNyfnaoO4s+9K6Di +31oxTBpqLI4dcrvg4xWl+YkEknXXaomMqM8hyDzfcAECgYEA9/zmjRpoTAoY3fu9 +mn16wxReFXZZZhqV0+c+gyYtao2Kf2pUNAdhD62HQv7KtAPPHKvLfL8PH0u7bzK0 +vVNzBUukwxGI7gsoTMdc3L5x4v9Yb6jUx7RrDZn93sDod/1f/sb56ARCFQoqbUck +dSjnVUyF/l5oeh6CgKhvtghJ/AcCgYEA5Lq4kL82qWjIuNUT/C3lzjPfQVU+WvQ9 +wa+x4B4mxm5r4na3AU1T8H+peh4YstAJUgscGfYnLzxuMGuP1ReIuWYy29eDptKl +WTzVZDcZrAPciP1FOL6jm03PT2UAEuoPRr4OHLg8DxoOqG8pxqk1izDSHG2Tof6l +0ToafeIALwECgYEA8wvLTgnOpI/U1WNP7aUDd0Rz/WbzsW1m4Lsn+lOleWPllIE6 +q4974mi5Q8ECG7IL/9aj5cw/XvXTauVwXIn4Ff2QKpr58AvBYJaX/cUtS0PlgfIf +MOczcK43MWUxscADoGmVLn9V4NcIw/dQ1P7U0zXfsXEHxoA2eTAb5HV1RWsCgYBd +TcXoVfgIV1Q6AcGrR1XNLd/OmOVc2PEwR2l6ERKkM3sS4HZ6s36gRpNt20Ub/D0x +GJMYDA+j9zTDz7zWokkFyCjLATkVHiyRIH2z6b4xK0oVH6vTIAFBYxZEPuEu1gfx +RaogEQ9+4ZRFJUOXZIMRCpNLQW/Nz0D4/oi7/SsyAQKBgHEA27Js8ivt+EFCBjwB +UbkW+LonDAXuUbw91lh5jICCigqUg73HNmV5xpoYI9JNPc6fy6wLyInVUC2w9tpO +eH2Rl8n79vQMLbzsFClGEC/Q1kAbK5bwUjlfvKBZjvE0RknWX9e1ZY04DSsunSrM +prS2eHVZ24hecd7j9XfAbHLC +-----END PRIVATE KEY----- diff --git a/tools.go b/tools.go index 9b56023..e207d46 100644 --- a/tools.go +++ b/tools.go @@ -5,4 +5,5 @@ package main import ( _ "github.com/mjl-/sherpadoc/cmd/sherpadoc" + _ "github.com/mjl-/sherpats/cmd/sherpats" ) diff --git a/tsc.sh b/tsc.sh new file mode 100755 index 0000000..5441933 --- /dev/null +++ b/tsc.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# - 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). +# - todo: get tsc to not turn multiline statements into one huge line. makes the dom-building statements unreadable in the js output. + +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: /' +unexpand -t4 <$out.spaces >$out +rm $out.spaces diff --git a/vendor/github.com/mjl-/bstore/doc.go b/vendor/github.com/mjl-/bstore/doc.go index f05f0b6..ad2cf0a 100644 --- a/vendor/github.com/mjl-/bstore/doc.go +++ b/vendor/github.com/mjl-/bstore/doc.go @@ -155,7 +155,7 @@ BoltDB returns Go values that are memory mapped to the database file. This means BoltDB/bstore database files cannot be transferred between machines with different endianness. BoltDB uses explicit widths for its types, so files can be transferred between 32bit and 64bit machines of same endianness. While -BoltDB returns read-only memory mapped Go values, bstore only ever returns +BoltDB returns read-only memory mapped byte slices, bstore only ever returns parsed/copied regular writable Go values that require no special programmer attention. diff --git a/vendor/github.com/mjl-/bstore/exec.go b/vendor/github.com/mjl-/bstore/exec.go index 122b314..71b5127 100644 --- a/vendor/github.com/mjl-/bstore/exec.go +++ b/vendor/github.com/mjl-/bstore/exec.go @@ -233,8 +233,23 @@ func (e *exec[T]) nextKey(write, value bool) ([]byte, T, error) { if collect { e.data = []pair[T]{} // Must be non-nil to get into e.data branch on function restart. } + // Every 1k keys we've seen, we'll check if the context has been canceled. If we + // wouldn't do this, a query that doesn't return any matches won't get canceled + // until it is finished. + keysSeen := 0 for { var xk, xv []byte + keysSeen++ + if keysSeen == 1024 { + select { + case <-q.ctxDone: + err := q.ctx.Err() + q.error(err) + return nil, zero, err + default: + } + keysSeen = 0 + } if e.forward == nil { // First time we are in this loop, we set up a cursor and e.forward. diff --git a/vendor/github.com/mjl-/bstore/export.go b/vendor/github.com/mjl-/bstore/export.go index 4a2dace..5e347d0 100644 --- a/vendor/github.com/mjl-/bstore/export.go +++ b/vendor/github.com/mjl-/bstore/export.go @@ -158,27 +158,27 @@ func (tx *Tx) Record(typeName, key string, fields *[]string) (map[string]any, er return nil, err } pkv := reflect.ValueOf(kv) - kind, err := typeKind(pkv.Type()) + k, err := typeKind(pkv.Type()) if err != nil { return nil, err } - if kind != tv.Fields[0].Type.Kind { + if k != tv.Fields[0].Type.Kind { // Convert from various int types above to required type. The ParseInt/ParseUint // calls already validated that the values fit. pkt := reflect.TypeOf(tv.Fields[0].Type.zeroKey()) pkv = pkv.Convert(pkt) } - k, err := packPK(pkv) + pk, err := packPK(pkv) if err != nil { return nil, err } tx.stats.Records.Get++ - bv := rb.Get(k) + bv := rb.Get(pk) if bv == nil { return nil, ErrAbsent } - record, err := parseMap(versions, k, bv) + record, err := parseMap(versions, pk, bv) if err != nil { return nil, err } diff --git a/vendor/github.com/mjl-/sherpa/handler.go b/vendor/github.com/mjl-/sherpa/handler.go index 1e3cafd..a4c6419 100644 --- a/vendor/github.com/mjl-/sherpa/handler.go +++ b/vendor/github.com/mjl-/sherpa/handler.go @@ -336,7 +336,7 @@ func adjustFunctionNameCapitals(s string, opts HandlerOpts) string { func gatherFunctions(functions map[string]reflect.Value, t reflect.Type, v reflect.Value, opts HandlerOpts) error { if t.Kind() != reflect.Struct { - return fmt.Errorf("sherpa sections must be a struct (not a ptr)") + return fmt.Errorf("sherpa sections must be a struct (is %v)", t) } for i := 0; i < t.NumMethod(); i++ { name := adjustFunctionNameCapitals(t.Method(i).Name, opts) @@ -347,7 +347,11 @@ func gatherFunctions(functions map[string]reflect.Value, t reflect.Type, v refle functions[name] = m } for i := 0; i < t.NumField(); i++ { - err := gatherFunctions(functions, t.Field(i).Type, v.Field(i), opts) + f := t.Field(i) + if !f.IsExported() { + continue + } + err := gatherFunctions(functions, f.Type, v.Field(i), opts) if err != nil { return err } @@ -492,7 +496,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { collector.JSON() hdr.Set("Content-Type", "application/json; charset=utf-8") hdr.Set("Cache-Control", "no-cache") - sherpaJSON := &*h.sherpaJSON + sherpaJSON := *h.sherpaJSON sherpaJSON.BaseURL = getBaseURL(r) + h.path err := json.NewEncoder(w).Encode(sherpaJSON) if err != nil { @@ -508,11 +512,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } collector.JavaScript() - hdr.Set("Content-Type", "text/javascript; charset=utf-8") - hdr.Set("Cache-Control", "no-cache") - sherpaJSON := &*h.sherpaJSON + sherpaJSON := *h.sherpaJSON sherpaJSON.BaseURL = getBaseURL(r) + h.path buf, err := json.Marshal(sherpaJSON) + if err != nil { + log.Println("marshal sherpa.json:", err) + http.Error(w, "500 - internal server error - marshal sherpa json failed", http.StatusInternalServerError) + return + } + hdr.Set("Content-Type", "text/javascript; charset=utf-8") + hdr.Set("Cache-Control", "no-cache") js := strings.Replace(sherpaJS, "{{.sherpaJSON}}", string(buf), -1) _, err = w.Write([]byte(js)) if err != nil { @@ -538,7 +547,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") if ct == "" { collector.ProtocolError() - respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: fmt.Sprintf("missing content-type")}}) + respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: "missing content-type"}}) return } mt, mtparams, err := mime.ParseMediaType(ct) @@ -552,8 +561,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: fmt.Sprintf(`unrecognized content-type %q, expecting "application/json"`, mt)}}) return } - charset, ok := mtparams["charset"] - if ok && strings.ToLower(charset) != "utf-8" { + if charset, chok := mtparams["charset"]; chok && strings.ToLower(charset) != "utf-8" { collector.ProtocolError() respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: fmt.Sprintf(`unexpected charset %q, expecting "utf-8"`, charset)}}) return @@ -561,7 +569,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { t0 := time.Now() r, xerr := h.call(r.Context(), name, fn, r.Body) - durationSec := float64(time.Now().Sub(t0)) / float64(time.Second) + durationSec := float64(time.Since(t0)) / float64(time.Second) if xerr != nil { switch err := xerr.(type) { case *InternalServerError: @@ -576,7 +584,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } else { var v interface{} - if raw, ok := r.(Raw); ok { + if raw, rok := r.(Raw); rok { v = raw } else { v = &response{Result: r} @@ -598,7 +606,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { collector.ProtocolError() - respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: fmt.Sprintf("could not parse query string")}}) + respondJSON(w, 200, &response{Error: &Error{Code: SherpaBadRequest, Message: "could not parse query string"}}) return } @@ -622,7 +630,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { t0 := time.Now() r, xerr := h.call(r.Context(), name, fn, strings.NewReader(body)) - durationSec := float64(time.Now().Sub(t0)) / float64(time.Second) + durationSec := float64(time.Since(t0)) / float64(time.Second) if xerr != nil { switch err := xerr.(type) { case *InternalServerError: diff --git a/vendor/github.com/mjl-/sherpadoc/README.txt b/vendor/github.com/mjl-/sherpadoc/README.txt index d9caf8d..d921b9c 100644 --- a/vendor/github.com/mjl-/sherpadoc/README.txt +++ b/vendor/github.com/mjl-/sherpadoc/README.txt @@ -15,7 +15,7 @@ MIT-licensed, see LICENSE. # todo - major cleanup required. too much parsing is done that can probably be handled by the go/* packages. -- check that all cases of embedding work +- check that all cases of embedding work (seems like we will include duplicates: when a struct has fields that override an embedded struct, we generate duplicate fields). - check that all cross-package referencing (ast.SelectorExpr) works - better cli syntax for replacements, and always replace based on fully qualified names. currently you need to specify both the fully qualified and unqualified type paths. - see if order of items in output depends on a map somewhere, i've seen diffs for generated jsons where a type was only moved, not modified. diff --git a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/main.go b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/main.go index af311e3..1c2d262 100644 --- a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/main.go +++ b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/main.go @@ -104,7 +104,7 @@ type namedType struct { // For kind is typeInts IntValues []struct { Name string - Value int + Value int64 Docs string } // For kind is typeStrings diff --git a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/parse.go b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/parse.go index cd75bc9..06090d9 100644 --- a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/parse.go +++ b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/parse.go @@ -162,7 +162,7 @@ func parseSection(t *doc.Type, pp *parsedPackage) *section { st := expr.(*ast.StructType) for _, f := range st.Fields.List { ident, ok := f.Type.(*ast.Ident) - if !ok { + if !ok || !ast.IsExported(ident.Name) { continue } name := ident.Name @@ -299,7 +299,7 @@ func ensureNamedType(t *doc.Type, sec *section, pp *parsedPackage) { tt.Text = t.Doc + ts.Comment.Text() switch nt.Name { - case "byte", "int16", "uint16", "int32", "uint32", "int", "uint": + case "byte", "int8", "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", "int", "uint": tt.Kind = typeInts case "string": tt.Kind = typeStrings @@ -331,13 +331,14 @@ func ensureNamedType(t *doc.Type, sec *section, pp *parsedPackage) { if tt.Kind != typeInts { logFatalLinef(pp, lit.Pos(), "int value for for non-int-enum %q", t.Name) } - v, err := strconv.ParseInt(lit.Value, 10, 64) + // Given JSON/JS lack of integers, restrict to what it can represent in its float. + v, err := strconv.ParseInt(lit.Value, 10, 52) check(err, "parse int literal") iv := struct { Name string - Value int + Value int64 Docs string - }{name, int(v), strings.TrimSpace(comment)} + }{name, v, strings.TrimSpace(comment)} tt.IntValues = append(tt.IntValues, iv) case token.STRING: if tt.Kind != typeStrings { diff --git a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/sherpa.go b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/sherpa.go index 60060db..ad29c0e 100644 --- a/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/sherpa.go +++ b/vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/sherpa.go @@ -51,9 +51,13 @@ func sherpaSection(sec *section) *sherpadoc.Section { case typeBytes: // todo: hack. find proper way to docment them. better for larger functionality: add generic support for lists of types. for now we'll fake this being a string... e := sherpadoc.Strings{ - Name: t.Name, - Docs: strings.TrimSpace(t.Text), - Values: []struct{Name string; Value string; Docs string}{}, + Name: t.Name, + Docs: strings.TrimSpace(t.Text), + Values: []struct { + Name string + Value string + Docs string + }{}, } doc.Strings = append(doc.Strings, e) default: diff --git a/vendor/github.com/mjl-/sherpadoc/sherpadoc.go b/vendor/github.com/mjl-/sherpadoc/sherpadoc.go index f1d1ae4..f81ab40 100644 --- a/vendor/github.com/mjl-/sherpadoc/sherpadoc.go +++ b/vendor/github.com/mjl-/sherpadoc/sherpadoc.go @@ -67,7 +67,7 @@ type Ints struct { Docs string Values []struct { Name string - Value int + Value int64 Docs string } } diff --git a/vendor/github.com/mjl-/sherpats/LICENSE b/vendor/github.com/mjl-/sherpats/LICENSE new file mode 100644 index 0000000..c080074 --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2018 Mechiel Lukkien + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/mjl-/sherpats/Makefile b/vendor/github.com/mjl-/sherpats/Makefile new file mode 100644 index 0000000..9b1b7fb --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/Makefile @@ -0,0 +1,24 @@ +SHELL=/bin/bash -o pipefail + +build: + go build ./... + go vet ./... + +test: + golint + go test -cover ./... + +coverage: + go test -coverprofile=coverage.out -test.outputdir . -- + go tool cover -html=coverage.out + +fmt: + go fmt ./... + +clean: + go clean + +# for testing generated typescript +setup: + -mkdir -p node_modules/.bin + npm install typescript@3.0.1 typescript-formatter@7.2.2 diff --git a/vendor/github.com/mjl-/sherpats/README.md b/vendor/github.com/mjl-/sherpats/README.md new file mode 100644 index 0000000..d7cd30b --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/README.md @@ -0,0 +1,31 @@ +# Sherpats + +Sherpats reads the (machine-readable) documentation for a [sherpa API](https://www.ueber.net/who/mjl/sherpa/) as generated by sherpadoc, and outputs a documented typescript module with all functions and types from the sherpa documentation. Example: + + sherpadoc MyAPI >myapi.json + sherpats < myapi.json >myapi.ts + +Read the [sherpats documentation](https://godoc.org/github.com/mjl-/sherpats). + + +# Tips + +At the beginning of each call of an API function, the generated +typescript code reads a localStorage variable "sherpats-debug". You +can use this to simulate network delay and inject failures into +your calls. Example: + + localStorage.setItem('sherpats-debug', JSON.stringify({waitMinMsec: 0, waitMaxMsec: 1000, failRate: 0.1})) + + +# Info + +Written by Mechiel Lukkien, mechiel@ueber.net, MIT-licensed, feedback welcome. + +# Todo + +- linewrap long comments for fields in generated types. +- check if identifiers (type names, function names) are keywords in typescript. if so, rename them so they are not, and don't clash with existing names. +- better error types? how is this normally done in typescript? error classes? +- add an example of a generated api +- write tests, both for go and for the generated typescript diff --git a/vendor/github.com/mjl-/sherpats/cmd/sherpats/main.go b/vendor/github.com/mjl-/sherpats/cmd/sherpats/main.go new file mode 100644 index 0000000..495cf8a --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/cmd/sherpats/main.go @@ -0,0 +1,50 @@ +// Command sherpats reads documentation from a sherpa API ("sherpadoc") +// and outputs a documented typescript module, optionally wrapped in a namespace, +// that exports all functions and types referenced in that machine-readable +// documentation. +// +// Example: +// +// sherpadoc MyAPI >myapi.json +// sherpats -bytes-to-string -slices-nullable -nullable-optional -namespace myapi myapi < myapi.json > myapi.ts +package main + +import ( + "flag" + "log" + "os" + + "github.com/mjl-/sherpats" +) + +func check(err error, action string) { + if err != nil { + log.Fatalf("%s: %s\n", action, err) + } +} + +func main() { + log.SetFlags(0) + + var opts sherpats.Options + flag.StringVar(&opts.Namespace, "namespace", "", "namespace to enclose generated typescript in") + flag.BoolVar(&opts.SlicesNullable, "slices-nullable", false, "generate nullable types in TypeScript for Go slices, to require TypeScript checks for null for slices") + flag.BoolVar(&opts.MapsNullable, "maps-nullable", false, "generate nullable types in TypeScript for Go maps, to require TypeScript checks for null for maps") + flag.BoolVar(&opts.NullableOptional, "nullable-optional", false, "for nullable types (include slices with -slices-nullable=true), generate optional fields in TypeScript and allow undefined as value") + flag.BoolVar(&opts.BytesToString, "bytes-to-string", false, "turn []uint8, also known as []byte, into string before generating the api, matching Go's JSON package that marshals []byte as base64-encoded string") + flag.Usage = func() { + log.Println("usage: sherpats [flags] { api-path-elem | baseURL }") + flag.PrintDefaults() + } + flag.Parse() + args := flag.Args() + if len(args) != 1 { + log.Print("unexpected arguments") + flag.Usage() + os.Exit(2) + } + apiName := args[0] + + err := sherpats.Generate(os.Stdin, os.Stdout, apiName, opts) + check(err, "generating typescript client") +} diff --git a/vendor/github.com/mjl-/sherpats/sherpats.go b/vendor/github.com/mjl-/sherpats/sherpats.go new file mode 100644 index 0000000..b6675f9 --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/sherpats.go @@ -0,0 +1,617 @@ +package sherpats + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/mjl-/sherpadoc" +) + +// Keywords in Typescript, from https://github.com/microsoft/TypeScript/blob/master/doc/spec.md. +var keywords = map[string]struct{}{ + "break": {}, + "case": {}, + "catch": {}, + "class": {}, + "const": {}, + "continue": {}, + "debugger": {}, + "default": {}, + "delete": {}, + "do": {}, + "else": {}, + "enum": {}, + "export": {}, + "extends": {}, + "false": {}, + "finally": {}, + "for": {}, + "function": {}, + "if": {}, + "import": {}, + "in": {}, + "instanceof": {}, + "new": {}, + "null": {}, + "return": {}, + "super": {}, + "switch": {}, + "this": {}, + "throw": {}, + "true": {}, + "try": {}, + "typeof": {}, + "var": {}, + "void": {}, + "while": {}, + "with": {}, + "implements": {}, + "interface": {}, + "let": {}, + "package": {}, + "private": {}, + "protected": {}, + "public": {}, + "static": {}, + "yield": {}, + "any": {}, + "boolean": {}, + "number": {}, + "string": {}, + "symbol": {}, + "abstract": {}, + "as": {}, + "async": {}, + "await": {}, + "constructor": {}, + "declare": {}, + "from": {}, + "get": {}, + "is": {}, + "module": {}, + "namespace": {}, + "of": {}, + "require": {}, + "set": {}, + "type": {}, +} + +type sherpaType interface { + TypescriptType() string +} + +// baseType can be one of: "any", "int16", etc +type baseType struct { + Name string +} + +// nullableType is: "nullable" . +type nullableType struct { + Type sherpaType +} + +// arrayType is: "[]" +type arrayType struct { + Type sherpaType +} + +// objectType is: "{}" +type objectType struct { + Value sherpaType +} + +// identType is: [a-zA-Z][a-zA-Z0-9]* +type identType struct { + Name string +} + +func (t baseType) TypescriptType() string { + switch t.Name { + case "bool": + return "boolean" + case "timestamp": + return "Date" + case "int8", "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", "float32", "float64": + return "number" + case "int64s", "uint64s": + return "string" + default: + return t.Name + } +} + +func isBaseOrIdent(t sherpaType) bool { + if _, ok := t.(baseType); ok { + return true + } + if _, ok := t.(identType); ok { + return true + } + return false +} + +func (t nullableType) TypescriptType() string { + if isBaseOrIdent(t.Type) { + return t.Type.TypescriptType() + " | null" + } + return "(" + t.Type.TypescriptType() + ") | null" +} + +func (t arrayType) TypescriptType() string { + if isBaseOrIdent(t.Type) { + return t.Type.TypescriptType() + "[] | null" + } + return "(" + t.Type.TypescriptType() + ")[] | null" +} + +func (t objectType) TypescriptType() string { + return fmt.Sprintf("{ [key: string]: %s }", t.Value.TypescriptType()) +} + +func (t identType) TypescriptType() string { + return t.Name +} + +type genError struct{ error } + +type Options struct { + // If not empty, the generated typescript is wrapped in a namespace. This allows + // easy compilation, with "tsc --module none" that uses the generated typescript + // api, while keeping all types/functions isolated. + Namespace string + + // With SlicesNullable and MapsNullable, generated typescript types are made + // nullable, with "| null". Go's JSON package marshals a nil slice/map to null, so + // it can be wise to make TypeScript consumers check that. Go code typically + // handles incoming nil and empty slices/maps in the same way. + SlicesNullable bool + MapsNullable bool + + // If nullables are optional, the generated typescript types allow the "undefined" + // value where nullable values are expected. This includes slices/maps when + // SlicesNullable/MapsNullable is set. When JavaScript marshals JSON, a field with the + // "undefined" value is treated as if the field doesn't exist, and isn't + // marshalled. The "undefined" value in an array is marshalled as null. It is + // common (though not always the case!) in Go server code to not make a difference + // between a missing field and a null value + NullableOptional bool + + // If set, "[]uint8" is changed into "string" before before interpreting the + // sherpadoc definitions. Go's JSON marshaller turns []byte (which is []uint8) into + // base64 strings. Having the same types in TypeScript is convenient. + // If SlicesNullable is set, the strings are made nullable. + BytesToString bool +} + +// Generate reads sherpadoc from in and writes a typescript file containing a +// client package to out. apiNameBaseURL is either an API name or sherpa +// baseURL, depending on whether it contains a slash. If it is a package name, the +// baseURL is created at runtime by adding the packageName to the current location. +func Generate(in io.Reader, out io.Writer, apiNameBaseURL string, opts Options) (retErr error) { + defer func() { + e := recover() + if e == nil { + return + } + g, ok := e.(genError) + if !ok { + panic(e) + } + retErr = error(g) + }() + + var doc sherpadoc.Section + err := json.NewDecoder(os.Stdin).Decode(&doc) + if err != nil { + panic(genError{fmt.Errorf("parsing sherpadoc json: %s", err)}) + } + + const sherpadocVersion = 1 + if doc.SherpadocVersion != sherpadocVersion { + panic(genError{fmt.Errorf("unexpected sherpadoc version %d, expected %d", doc.SherpadocVersion, sherpadocVersion)}) + } + + if opts.BytesToString { + toString := func(tw []string) []string { + n := len(tw) - 1 + for i := 0; i < n; i++ { + if tw[i] == "[]" && tw[i+1] == "uint8" { + if opts.SlicesNullable && (i == 0 || tw[i-1] != "nullable") { + tw[i] = "nullable" + tw[i+1] = "string" + i++ + } else { + tw[i] = "string" + copy(tw[i+1:], tw[i+2:]) + tw = tw[:len(tw)-1] + n-- + } + } + } + return tw + } + + var bytesToString func(sec *sherpadoc.Section) + bytesToString = func(sec *sherpadoc.Section) { + for i := range sec.Functions { + for j := range sec.Functions[i].Params { + sec.Functions[i].Params[j].Typewords = toString(sec.Functions[i].Params[j].Typewords) + } + for j := range sec.Functions[i].Returns { + sec.Functions[i].Returns[j].Typewords = toString(sec.Functions[i].Returns[j].Typewords) + } + } + for i := range sec.Structs { + for j := range sec.Structs[i].Fields { + sec.Structs[i].Fields[j].Typewords = toString(sec.Structs[i].Fields[j].Typewords) + } + } + for _, s := range sec.Sections { + bytesToString(s) + } + } + bytesToString(&doc) + } + + // Validate the sherpadoc. + err = sherpadoc.Check(&doc) + if err != nil { + panic(genError{err}) + } + + // Make a copy, the ugly way. We'll strip the documentation out before including + // the types. We need types for runtime type checking, but the docs just bloat the + // size. + var typesdoc sherpadoc.Section + if typesbuf, err := json.Marshal(doc); err != nil { + panic(genError{fmt.Errorf("marshal sherpadoc for types: %s", err)}) + } else if err := json.Unmarshal(typesbuf, &typesdoc); err != nil { + panic(genError{fmt.Errorf("unmarshal sherpadoc for types: %s", err)}) + } + for i := range typesdoc.Structs { + typesdoc.Structs[i].Docs = "" + for j := range typesdoc.Structs[i].Fields { + typesdoc.Structs[i].Fields[j].Docs = "" + } + } + for i := range typesdoc.Ints { + typesdoc.Ints[i].Docs = "" + for j := range typesdoc.Ints[i].Values { + typesdoc.Ints[i].Values[j].Docs = "" + } + } + for i := range typesdoc.Strings { + typesdoc.Strings[i].Docs = "" + for j := range typesdoc.Strings[i].Values { + typesdoc.Strings[i].Values[j].Docs = "" + } + } + + bout := bufio.NewWriter(out) + xprintf := func(format string, args ...interface{}) { + _, err := fmt.Fprintf(out, format, args...) + if err != nil { + panic(genError{err}) + } + } + + xprintMultiline := func(indent, docs string, always bool) []string { + lines := docLines(docs) + if len(lines) == 1 && !always { + return lines + } + for _, line := range lines { + xprintf("%s// %s\n", indent, line) + } + return lines + } + + xprintSingleline := func(lines []string) { + if len(lines) != 1 { + return + } + xprintf(" // %s", lines[0]) + } + + // Type and function names could be typescript keywords. If they are, give them a different name. + typescriptNames := map[string]string{} + typescriptName := func(name string, names map[string]string) string { + if _, ok := keywords[name]; !ok { + return name + } + n := names[name] + if n != "" { + return n + } + for i := 0; ; i++ { + n = fmt.Sprintf("%s%d", name, i) + if _, ok := names[n]; ok { + continue + } + names[name] = n + return n + } + } + + structTypes := map[string]bool{} + stringsTypes := map[string]bool{} + intsTypes := map[string]bool{} + + var generateTypes func(sec *sherpadoc.Section) + generateTypes = func(sec *sherpadoc.Section) { + for _, t := range sec.Structs { + structTypes[t.Name] = true + xprintMultiline("", t.Docs, true) + name := typescriptName(t.Name, typescriptNames) + xprintf("export interface %s {\n", name) + names := map[string]string{} + for _, f := range t.Fields { + lines := xprintMultiline("", f.Docs, false) + what := fmt.Sprintf("field %s for type %s", f.Name, t.Name) + optional := "" + if opts.NullableOptional && f.Typewords[0] == "nullable" || opts.NullableOptional && (opts.SlicesNullable && f.Typewords[0] == "[]" || opts.MapsNullable && f.Typewords[0] == "{}") { + optional = "?" + } + xprintf("\t%s%s: %s", typescriptName(f.Name, names), optional, typescriptType(what, f.Typewords)) + xprintSingleline(lines) + xprintf("\n") + } + xprintf("}\n\n") + } + + for _, t := range sec.Ints { + intsTypes[t.Name] = true + xprintMultiline("", t.Docs, true) + name := typescriptName(t.Name, typescriptNames) + if len(t.Values) == 0 { + xprintf("export type %s = number\n\n", name) + continue + } + xprintf("export enum %s {\n", name) + names := map[string]string{} + for _, v := range t.Values { + lines := xprintMultiline("\t", v.Docs, false) + xprintf("\t%s = %d,", typescriptName(v.Name, names), v.Value) + xprintSingleline(lines) + xprintf("\n") + } + xprintf("}\n\n") + } + + for _, t := range sec.Strings { + stringsTypes[t.Name] = true + xprintMultiline("", t.Docs, true) + name := typescriptName(t.Name, typescriptNames) + if len(t.Values) == 0 { + xprintf("export type %s = string\n\n", name) + continue + } + xprintf("export enum %s {\n", name) + names := map[string]string{} + for _, v := range t.Values { + lines := xprintMultiline("\t", v.Docs, false) + s := mustMarshalJSON(v.Value) + xprintf("\t%s = %s,", typescriptName(v.Name, names), s) + xprintSingleline(lines) + xprintf("\n") + } + xprintf("}\n\n") + } + + for _, subsec := range sec.Sections { + generateTypes(subsec) + } + } + + var generateFunctionTypes func(sec *sherpadoc.Section) + generateFunctionTypes = func(sec *sherpadoc.Section) { + for _, typ := range sec.Structs { + xprintf(" %s: %s,\n", mustMarshalJSON(typ.Name), mustMarshalJSON(typ)) + } + for _, typ := range sec.Ints { + xprintf(" %s: %s,\n", mustMarshalJSON(typ.Name), mustMarshalJSON(typ)) + } + for _, typ := range sec.Strings { + xprintf(" %s: %s,\n", mustMarshalJSON(typ.Name), mustMarshalJSON(typ)) + } + + for _, subsec := range sec.Sections { + generateFunctionTypes(subsec) + } + } + + var generateParser func(sec *sherpadoc.Section) + generateParser = func(sec *sherpadoc.Section) { + for _, typ := range sec.Structs { + xprintf(" %s: (v: any) => parse(%s, v) as %s,\n", typ.Name, mustMarshalJSON(typ.Name), typ.Name) + } + for _, typ := range sec.Ints { + xprintf(" %s: (v: any) => parse(%s, v) as %s,\n", typ.Name, mustMarshalJSON(typ.Name), typ.Name) + } + for _, typ := range sec.Strings { + xprintf(" %s: (v: any) => parse(%s, v) as %s,\n", typ.Name, mustMarshalJSON(typ.Name), typ.Name) + } + + for _, subsec := range sec.Sections { + generateParser(subsec) + } + } + + var generateSectionDocs func(sec *sherpadoc.Section) + generateSectionDocs = func(sec *sherpadoc.Section) { + xprintMultiline("", sec.Docs, true) + for _, subsec := range sec.Sections { + xprintf("//\n") + xprintf("// # %s\n", subsec.Name) + generateSectionDocs(subsec) + } + } + + var generateFunctions func(sec *sherpadoc.Section) + generateFunctions = func(sec *sherpadoc.Section) { + for i, fn := range sec.Functions { + whatParam := "pararameter for " + fn.Name + paramNameTypes := []string{} + paramNames := []string{} + sherpaParamTypes := [][]string{} + names := map[string]string{} + for _, p := range fn.Params { + name := typescriptName(p.Name, names) + v := fmt.Sprintf("%s: %s", name, typescriptType(whatParam, p.Typewords)) + paramNameTypes = append(paramNameTypes, v) + paramNames = append(paramNames, name) + sherpaParamTypes = append(sherpaParamTypes, p.Typewords) + } + + var returnType string + switch len(fn.Returns) { + case 0: + returnType = "void" + case 1: + what := "return type for " + fn.Name + returnType = typescriptType(what, fn.Returns[0].Typewords) + default: + var types []string + what := "return type for " + fn.Name + for _, t := range fn.Returns { + types = append(types, typescriptType(what, t.Typewords)) + } + returnType = fmt.Sprintf("[%s]", strings.Join(types, ", ")) + } + sherpaReturnTypes := [][]string{} + for _, a := range fn.Returns { + sherpaReturnTypes = append(sherpaReturnTypes, a.Typewords) + } + + name := typescriptName(fn.Name, typescriptNames) + xprintMultiline("\t", fn.Docs, true) + xprintf("\tasync %s(%s): Promise<%s> {\n", name, strings.Join(paramNameTypes, ", "), returnType) + xprintf("\t\tconst fn: string = %s\n", mustMarshalJSON(fn.Name)) + xprintf("\t\tconst paramTypes: string[][] = %s\n", mustMarshalJSON(sherpaParamTypes)) + xprintf("\t\tconst returnTypes: string[][] = %s\n", mustMarshalJSON(sherpaReturnTypes)) + xprintf("\t\tconst params: any[] = [%s]\n", strings.Join(paramNames, ", ")) + xprintf("\t\treturn await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as %s\n", returnType) + xprintf("\t}\n") + if i < len(sec.Functions)-1 { + xprintf("\n") + } + } + + for _, s := range sec.Sections { + generateFunctions(s) + } + } + + xprintf("// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY\n\n") + if opts.Namespace != "" { + xprintf("namespace %s {\n\n", opts.Namespace) + } + generateTypes(&doc) + xprintf("export const structTypes: {[typename: string]: boolean} = %s\n", mustMarshalJSON(structTypes)) + xprintf("export const stringsTypes: {[typename: string]: boolean} = %s\n", mustMarshalJSON(stringsTypes)) + xprintf("export const intsTypes: {[typename: string]: boolean} = %s\n", mustMarshalJSON(intsTypes)) + xprintf("export const types: TypenameMap = {\n") + generateFunctionTypes(&typesdoc) + xprintf("}\n\n") + xprintf("export const parser = {\n") + generateParser(&doc) + xprintf("}\n\n") + generateSectionDocs(&doc) + xprintf(`let defaultOptions: ClientOptions = {slicesNullable: %v, mapsNullable: %v, nullableOptional: %v} + +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 }) + } + +`, opts.SlicesNullable, opts.MapsNullable, opts.NullableOptional) + generateFunctions(&doc) + xprintf("}\n\n") + + const findBaseURL = `(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_NAME/' +})()` + + var apiJS string + if strings.Contains(apiNameBaseURL, "/") { + apiJS = mustMarshalJSON(apiNameBaseURL) + } else { + apiJS = strings.Replace(findBaseURL, "API_NAME", apiNameBaseURL, -1) + } + xprintf("%s\n", strings.Replace(libTS, "BASEURL", apiJS, -1)) + if opts.Namespace != "" { + xprintf("}\n") + } + + err = bout.Flush() + if err != nil { + panic(genError{err}) + } + return nil +} + +func typescriptType(what string, typeTokens []string) string { + t := parseType(what, typeTokens) + return t.TypescriptType() +} + +func parseType(what string, tokens []string) sherpaType { + checkOK := func(ok bool, v interface{}, msg string) { + if !ok { + panic(genError{fmt.Errorf("invalid type for %s: %s, saw %q", what, msg, v)}) + } + } + checkOK(len(tokens) > 0, tokens, "need at least one element") + s := tokens[0] + tokens = tokens[1:] + switch s { + case "any", "bool", "int8", "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", "int64s", "uint64s", "float32", "float64", "string", "timestamp": + if len(tokens) != 0 { + checkOK(false, tokens, "leftover tokens after base type") + } + return baseType{s} + case "nullable": + return nullableType{parseType(what, tokens)} + case "[]": + return arrayType{parseType(what, tokens)} + case "{}": + return objectType{parseType(what, tokens)} + default: + if len(tokens) != 0 { + checkOK(false, tokens, "leftover tokens after identifier type") + } + return identType{s} + } +} + +func docLines(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func mustMarshalJSON(v interface{}) string { + buf, err := json.Marshal(v) + if err != nil { + panic(genError{fmt.Errorf("marshalling json: %s", err)}) + } + return string(buf) +} diff --git a/vendor/github.com/mjl-/sherpats/ts.go b/vendor/github.com/mjl-/sherpats/ts.go new file mode 100644 index 0000000..07d3aac --- /dev/null +++ b/vendor/github.com/mjl-/sherpats/ts.go @@ -0,0 +1,387 @@ +package sherpats + +const libTS = `export const defaultBaseURL = BASEURL + +// 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/vendor/golang.org/x/net/html/render.go b/vendor/golang.org/x/net/html/render.go index 8b28031..e8c1233 100644 --- a/vendor/golang.org/x/net/html/render.go +++ b/vendor/golang.org/x/net/html/render.go @@ -194,9 +194,8 @@ func render1(w writer, n *Node) error { } } - // Render any child nodes. - switch n.Data { - case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp": + // Render any child nodes + if childTextNodesAreLiteral(n) { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == TextNode { if _, err := w.WriteString(c.Data); err != nil { @@ -213,7 +212,7 @@ func render1(w writer, n *Node) error { // last element in the file, with no closing tag. return plaintextAbort } - default: + } else { for c := n.FirstChild; c != nil; c = c.NextSibling { if err := render1(w, c); err != nil { return err @@ -231,6 +230,27 @@ func render1(w writer, n *Node) error { return w.WriteByte('>') } +func childTextNodesAreLiteral(n *Node) bool { + // Per WHATWG HTML 13.3, if the parent of the current node is a style, + // script, xmp, iframe, noembed, noframes, or plaintext element, and the + // current node is a text node, append the value of the node's data + // literally. The specification is not explicit about it, but we only + // enforce this if we are in the HTML namespace (i.e. when the namespace is + // ""). + // NOTE: we also always include noscript elements, although the + // specification states that they should only be rendered as such if + // scripting is enabled for the node (which is not something we track). + if n.Namespace != "" { + return false + } + switch n.Data { + case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp": + return true + default: + return false + } +} + // writeQuoted writes s to w surrounded by quotes. Normally it will use double // quotes, but if s contains a double quote, it will use single quotes. // It is used for writing the identifiers in a doctype declaration. diff --git a/vendor/modules.txt b/vendor/modules.txt index 8d05843..5246c49 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -11,22 +11,26 @@ github.com/golang/protobuf/ptypes/timestamp # github.com/matttproud/golang_protobuf_extensions v1.0.1 ## explicit github.com/matttproud/golang_protobuf_extensions/pbutil -# github.com/mjl-/bstore v0.0.1 +# github.com/mjl-/bstore v0.0.2 ## explicit; go 1.19 github.com/mjl-/bstore # github.com/mjl-/sconf v0.0.4 ## explicit; go 1.12 github.com/mjl-/sconf -# github.com/mjl-/sherpa v0.6.5 +# github.com/mjl-/sherpa v0.6.6 ## explicit; go 1.12 github.com/mjl-/sherpa -# github.com/mjl-/sherpadoc v0.0.10 +# github.com/mjl-/sherpadoc v0.0.12 ## explicit; go 1.16 github.com/mjl-/sherpadoc github.com/mjl-/sherpadoc/cmd/sherpadoc # github.com/mjl-/sherpaprom v0.0.2 ## explicit; go 1.12 github.com/mjl-/sherpaprom +# github.com/mjl-/sherpats v0.0.4 +## explicit; go 1.12 +github.com/mjl-/sherpats +github.com/mjl-/sherpats/cmd/sherpats # github.com/mjl-/xfmt v0.0.0-20190521151243-39d9c00752ce ## explicit; go 1.12 github.com/mjl-/xfmt @@ -71,7 +75,7 @@ golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile golang.org/x/mod/module golang.org/x/mod/semver -# golang.org/x/net v0.12.0 +# golang.org/x/net v0.13.0 ## explicit; go 1.17 golang.org/x/net/html golang.org/x/net/html/atom diff --git a/verifydata.go b/verifydata.go index 1558fe7..db4284a 100644 --- a/verifydata.go +++ b/verifydata.go @@ -242,11 +242,21 @@ possibly making them potentially no longer readable by the previous version. }) checkf(err, dbpath, "reading mailboxes to check uidnext consistency") + mbCounts := map[int64]store.MailboxCounts{} err = bstore.QueryDB[store.Message](ctxbg, db).ForEach(func(m store.Message) error { - if mb := mailboxes[m.MailboxID]; m.UID >= mb.UIDNext { + mb := mailboxes[m.MailboxID] + if m.UID >= mb.UIDNext { checkf(errors.New(`inconsistent uidnext for message/mailbox, see "mox fixuidmeta"`), dbpath, "message id %d in mailbox %q (id %d) has uid %d >= mailbox uidnext %d", m.ID, mb.Name, mb.ID, m.UID, mb.UIDNext) } + if m.ModSeq < m.CreateSeq { + checkf(errors.New(`inconsistent modseq/createseq for message`), dbpath, "message id %d in mailbox %q (id %d) has modseq %d < createseq %d", m.ID, mb.Name, mb.ID, m.ModSeq, m.CreateSeq) + } + + mc := mbCounts[mb.ID] + mc.Add(m.MailboxCounts()) + mbCounts[mb.ID] = mc + if m.Expunged { return nil } @@ -257,6 +267,13 @@ possibly making them potentially no longer readable by the previous version. return nil }) checkf(err, dbpath, "reading messages in account database to check files") + + for _, mb := range mailboxes { + // We only check if database doesn't have zero values, i.e. not yet set. + if mb.HaveCounts && mb.MailboxCounts != mbCounts[mb.ID] { + checkf(errors.New(`wrong mailbox counts, see "mox recalculatemailboxcounts"`), dbpath, "mailbox %q (id %d) has wrong counts %s, should be %s", mb.Name, mb.ID, mb.MailboxCounts, mbCounts[mb.ID]) + } + } } // Walk through all files in the msg directory. Warn about files that weren't in diff --git a/http/account.go b/webaccount/account.go similarity index 80% rename from http/account.go rename to webaccount/account.go index 8d6e81d..e1ab075 100644 --- a/http/account.go +++ b/webaccount/account.go @@ -1,4 +1,4 @@ -package http +package webaccount import ( "archive/tar" @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "net" "net/http" @@ -18,6 +19,7 @@ import ( _ "embed" "github.com/mjl-/sherpa" + "github.com/mjl-/sherpadoc" "github.com/mjl-/sherpaprom" "github.com/mjl-/mox/config" @@ -29,6 +31,12 @@ import ( "github.com/mjl-/mox/store" ) +func init() { + mox.LimitersInit() +} + +var xlog = mlog.New("webaccount") + //go:embed accountapi.json var accountapiJSON []byte @@ -39,6 +47,14 @@ var accountDoc = mustParseAPI("account", accountapiJSON) var accountSherpaHandler http.Handler +func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) { + err := json.Unmarshal(buf, &doc) + if err != nil { + xlog.Fatalx("parsing api docs", err, mlog.Field("api", api)) + } + return doc +} + func init() { collector, err := sherpaprom.NewCollector("moxaccount", nil) if err != nil { @@ -51,19 +67,29 @@ func init() { } } +func xcheckf(ctx context.Context, err error, format string, args ...any) { + if err == nil { + return + } + msg := fmt.Sprintf(format, args...) + errmsg := fmt.Sprintf("%s: %s", msg, err) + xlog.WithContext(ctx).Errorx(msg, err) + panic(&sherpa.Error{Code: "server:error", Message: errmsg}) +} + // 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. type Account struct{} -// check http basic auth, returns account name if valid, and writes http response -// and returns empty string otherwise. -func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) string { +// CheckAuth checks http basic auth, returns login address and account name if +// valid, and writes http response and returns empty string otherwise. +func CheckAuth(ctx context.Context, log *mlog.Log, kind string, w http.ResponseWriter, r *http.Request) (address, account string) { authResult := "error" start := time.Now() var addr *net.TCPAddr defer func() { - metrics.AuthenticationInc("httpaccount", "httpbasic", authResult) + metrics.AuthenticationInc(kind, "httpbasic", authResult) if authResult == "ok" && addr != nil { mox.LimiterFailedAuth.Reset(addr.IP, start) } @@ -78,13 +104,13 @@ func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, remoteIP = addr.IP } if remoteIP != nil && !mox.LimiterFailedAuth.Add(remoteIP, start, 1) { - metrics.AuthenticationRatelimitedInc("httpaccount") + metrics.AuthenticationRatelimitedInc(kind) http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests) - return "" + return "", "" } // store.OpenEmailAuth has an auth cache, so we don't bcrypt for every auth attempt. - if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") { + if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Basic ") { } else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil { log.Debugx("parsing base64", err) } else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 { @@ -100,15 +126,15 @@ func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, accName := acc.Name err := acc.Close() log.Check(err, "closing account") - return accName + return t[0], accName } // note: browsers don't display the realm to prevent users getting confused by malicious realm messages. - w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`) - http.Error(w, "http 401 - unauthorized - mox account - login with email address and password", http.StatusUnauthorized) - return "" + w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with account email address and password"`) + http.Error(w, "http 401 - unauthorized - mox account - login with account email address and password", http.StatusUnauthorized) + return "", "" } -func accountHandle(w http.ResponseWriter, r *http.Request) { +func Handle(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) @@ -169,12 +195,16 @@ func accountHandle(w http.ResponseWriter, r *http.Request) { } } - accName := checkAccountAuth(ctx, log, w, r) + _, accName := CheckAuth(ctx, log, "webaccount", w, r) if accName == "" { // Response already sent. return } + if lw, ok := w.(interface{ AddField(p mlog.Pair) }); ok { + lw.AddField(mlog.Field("authaccount", accName)) + } + switch r.URL.Path { case "/": if r.Method != "GET" { @@ -185,7 +215,7 @@ func accountHandle(w http.ResponseWriter, r *http.Request) { 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("http/account.html") + f, err := os.Open("webaccount/account.html") if err == nil { defer f.Close() _, _ = io.Copy(w, f) @@ -284,7 +314,8 @@ func accountHandle(w http.ResponseWriter, r *http.Request) { default: if strings.HasPrefix(r.URL.Path, "/api/") { - accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accName))) + ctx = context.WithValue(ctx, authCtxKey, accName) + accountSherpaHandler.ServeHTTP(w, r.WithContext(ctx)) return } http.NotFound(w, r) @@ -313,16 +344,27 @@ func (Account) SetPassword(ctx context.Context, password string) { xcheckf(ctx, err, "setting password") } -// Destinations returns 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. -func (Account) Destinations(ctx context.Context) (dns.Domain, map[string]config.Destination) { +// 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. +func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]config.Destination) { accountName := ctx.Value(authCtxKey).(string) accConf, ok := mox.Conf.Account(accountName) if !ok { xcheckf(ctx, errors.New("not found"), "looking up account") } - return accConf.DNSDomain, accConf.Destinations + return accConf.FullName, accConf.DNSDomain, accConf.Destinations +} + +func (Account) AccountSaveFullName(ctx context.Context, fullName string) { + accountName := ctx.Value(authCtxKey).(string) + _, ok := mox.Conf.Account(accountName) + if !ok { + xcheckf(ctx, errors.New("not found"), "looking up account") + } + err := mox.AccountFullNameSave(ctx, accountName, fullName) + xcheckf(ctx, err, "saving account full name") } // DestinationSave updates a destination. diff --git a/http/account.html b/webaccount/account.html similarity index 94% rename from http/account.html rename to webaccount/account.html index 54f9633..7688fb0 100644 --- a/http/account.html +++ b/webaccount/account.html @@ -4,6 +4,7 @@ Mox Account + + + +
Loading...
+ + + + + + + diff --git a/webmail/msg.js b/webmail/msg.js new file mode 100644 index 0000000..40e9461 --- /dev/null +++ b/webmail/msg.js @@ -0,0 +1,966 @@ +"use strict"; +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY +var api; +(function (api) { + // Validation of "message From" domain. + let Validation; + (function (Validation) { + Validation[Validation["ValidationUnknown"] = 0] = "ValidationUnknown"; + Validation[Validation["ValidationStrict"] = 1] = "ValidationStrict"; + Validation[Validation["ValidationDMARC"] = 2] = "ValidationDMARC"; + Validation[Validation["ValidationRelaxed"] = 3] = "ValidationRelaxed"; + Validation[Validation["ValidationPass"] = 4] = "ValidationPass"; + Validation[Validation["ValidationNeutral"] = 5] = "ValidationNeutral"; + Validation[Validation["ValidationTemperror"] = 6] = "ValidationTemperror"; + Validation[Validation["ValidationPermerror"] = 7] = "ValidationPermerror"; + Validation[Validation["ValidationFail"] = 8] = "ValidationFail"; + Validation[Validation["ValidationSoftfail"] = 9] = "ValidationSoftfail"; + Validation[Validation["ValidationNone"] = 10] = "ValidationNone"; + })(Validation = api.Validation || (api.Validation = {})); + // AttachmentType is for filtering by attachment type. + let AttachmentType; + (function (AttachmentType) { + AttachmentType["AttachmentIndifferent"] = ""; + AttachmentType["AttachmentNone"] = "none"; + AttachmentType["AttachmentAny"] = "any"; + AttachmentType["AttachmentImage"] = "image"; + AttachmentType["AttachmentPDF"] = "pdf"; + AttachmentType["AttachmentArchive"] = "archive"; + AttachmentType["AttachmentSpreadsheet"] = "spreadsheet"; + AttachmentType["AttachmentDocument"] = "document"; + AttachmentType["AttachmentPresentation"] = "presentation"; + })(AttachmentType = api.AttachmentType || (api.AttachmentType = {})); + api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "Request": true, "SpecialUse": true, "SubmitMessage": true }; + api.stringsTypes = { "AttachmentType": true, "Localpart": true }; + api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; + api.types = { + "Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] }, + "Query": { "Name": "Query", "Docs": "", "Fields": [{ "Name": "OrderAsc", "Docs": "", "Typewords": ["bool"] }, { "Name": "Filter", "Docs": "", "Typewords": ["Filter"] }, { "Name": "NotFilter", "Docs": "", "Typewords": ["NotFilter"] }] }, + "Filter": { "Name": "Filter", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxChildrenIncluded", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Oldest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Newest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "SizeMin", "Docs": "", "Typewords": ["int64"] }, { "Name": "SizeMax", "Docs": "", "Typewords": ["int64"] }] }, + "NotFilter": { "Name": "NotFilter", "Docs": "", "Fields": [{ "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }] }, + "Page": { "Name": "Page", "Docs": "", "Fields": [{ "Name": "AnchorMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Count", "Docs": "", "Typewords": ["int32"] }, { "Name": "DestMessageID", "Docs": "", "Typewords": ["int64"] }] }, + "ParsedMessage": { "Name": "ParsedMessage", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }, { "Name": "Headers", "Docs": "", "Typewords": ["{}", "[]", "string"] }, { "Name": "Texts", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HasHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListReplyAddress", "Docs": "", "Typewords": ["nullable", "MessageAddress"] }] }, + "Part": { "Name": "Part", "Docs": "", "Fields": [{ "Name": "BoundaryOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "HeaderOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "BodyOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "EndOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "RawLineCount", "Docs": "", "Typewords": ["int64"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "MediaType", "Docs": "", "Typewords": ["string"] }, { "Name": "MediaSubType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentDescription", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTransferEncoding", "Docs": "", "Typewords": ["string"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["nullable", "Envelope"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Part"] }, { "Name": "Message", "Docs": "", "Typewords": ["nullable", "Part"] }] }, + "Envelope": { "Name": "Envelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, + "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, + "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, + "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, + "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, + "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, + "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, + "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, + "MessageItem": { "Name": "MessageItem", "Docs": "", "Fields": [{ "Name": "Message", "Docs": "", "Typewords": ["Message"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["MessageEnvelope"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "Attachment"] }, { "Name": "IsSigned", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsEncrypted", "Docs": "", "Typewords": ["bool"] }, { "Name": "FirstLine", "Docs": "", "Typewords": ["string"] }] }, + "Message": { "Name": "Message", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "CreateSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Expunged", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxOrigID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxDestinedID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked1", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked2", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked3", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLODomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MailFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "RcptToLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RcptToDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MsgFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromOrgDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLOValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "EHLOValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MailFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MsgFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "DKIMDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageHash", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "TrainedJunk", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "ParsedBuf", "Docs": "", "Typewords": ["nullable", "string"] }] }, + "MessageEnvelope": { "Name": "MessageEnvelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Attachment": { "Name": "Attachment", "Docs": "", "Fields": [{ "Name": "Path", "Docs": "", "Typewords": ["[]", "int32"] }, { "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }] }, + "EventViewChanges": { "Name": "EventViewChanges", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Changes", "Docs": "", "Typewords": ["[]", "[]", "any"] }] }, + "ChangeMsgAdd": { "Name": "ChangeMsgAdd", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageItem", "Docs": "", "Typewords": ["MessageItem"] }] }, + "Flags": { "Name": "Flags", "Docs": "", "Fields": [{ "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMsgRemove": { "Name": "ChangeMsgRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UIDs", "Docs": "", "Typewords": ["[]", "UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }] }, + "ChangeMsgFlags": { "Name": "ChangeMsgFlags", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Mask", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxRemove": { "Name": "ChangeMailboxRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }] }, + "ChangeMailboxAdd": { "Name": "ChangeMailboxAdd", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["Mailbox"] }] }, + "ChangeMailboxRename": { "Name": "ChangeMailboxRename", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "OldName", "Docs": "", "Typewords": ["string"] }, { "Name": "NewName", "Docs": "", "Typewords": ["string"] }, { "Name": "Flags", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxCounts": { "Name": "ChangeMailboxCounts", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "ChangeMailboxSpecialUse": { "Name": "ChangeMailboxSpecialUse", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "SpecialUse", "Docs": "", "Typewords": ["SpecialUse"] }] }, + "SpecialUse": { "Name": "SpecialUse", "Docs": "", "Fields": [{ "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMailboxKeywords": { "Name": "ChangeMailboxKeywords", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "UID": { "Name": "UID", "Docs": "", "Values": null }, + "ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null }, + "Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] }, + "AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] }, + "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, + }; + api.parser = { + Request: (v) => api.parse("Request", v), + Query: (v) => api.parse("Query", v), + Filter: (v) => api.parse("Filter", v), + NotFilter: (v) => api.parse("NotFilter", v), + Page: (v) => api.parse("Page", v), + ParsedMessage: (v) => api.parse("ParsedMessage", v), + Part: (v) => api.parse("Part", v), + Envelope: (v) => api.parse("Envelope", v), + Address: (v) => api.parse("Address", v), + MessageAddress: (v) => api.parse("MessageAddress", v), + Domain: (v) => api.parse("Domain", v), + SubmitMessage: (v) => api.parse("SubmitMessage", v), + File: (v) => api.parse("File", v), + ForwardAttachments: (v) => api.parse("ForwardAttachments", v), + Mailbox: (v) => api.parse("Mailbox", v), + EventStart: (v) => api.parse("EventStart", v), + DomainAddressConfig: (v) => api.parse("DomainAddressConfig", v), + EventViewErr: (v) => api.parse("EventViewErr", v), + EventViewReset: (v) => api.parse("EventViewReset", v), + EventViewMsgs: (v) => api.parse("EventViewMsgs", v), + MessageItem: (v) => api.parse("MessageItem", v), + Message: (v) => api.parse("Message", v), + MessageEnvelope: (v) => api.parse("MessageEnvelope", v), + Attachment: (v) => api.parse("Attachment", v), + EventViewChanges: (v) => api.parse("EventViewChanges", v), + ChangeMsgAdd: (v) => api.parse("ChangeMsgAdd", v), + Flags: (v) => api.parse("Flags", v), + ChangeMsgRemove: (v) => api.parse("ChangeMsgRemove", v), + ChangeMsgFlags: (v) => api.parse("ChangeMsgFlags", v), + ChangeMailboxRemove: (v) => api.parse("ChangeMailboxRemove", v), + ChangeMailboxAdd: (v) => api.parse("ChangeMailboxAdd", v), + ChangeMailboxRename: (v) => api.parse("ChangeMailboxRename", v), + ChangeMailboxCounts: (v) => api.parse("ChangeMailboxCounts", v), + ChangeMailboxSpecialUse: (v) => api.parse("ChangeMailboxSpecialUse", v), + SpecialUse: (v) => api.parse("SpecialUse", v), + ChangeMailboxKeywords: (v) => api.parse("ChangeMailboxKeywords", v), + UID: (v) => api.parse("UID", v), + ModSeq: (v) => api.parse("ModSeq", v), + Validation: (v) => api.parse("Validation", v), + AttachmentType: (v) => api.parse("AttachmentType", v), + Localpart: (v) => api.parse("Localpart", v), + }; + let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true }; + class Client { + 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 }); + } + // Token returns a token to use for an SSE connection. A token can only be used for + // a single SSE connection. Tokens are stored in memory for a maximum of 1 minute, + // with at most 10 unused tokens (the most recently created) per account. + async Token() { + const fn = "Token"; + const paramTypes = []; + const returnTypes = [["string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Requests sends a new request for an open SSE connection. Any currently active + // request for the connection will be canceled, but this is done asynchrously, so + // the SSE connection may still send results for the previous request. Callers + // should take care to ignore such results. If req.Cancel is set, no new request is + // started. + async Request(req) { + const fn = "Request"; + const paramTypes = [["Request"]]; + const returnTypes = []; + const params = [req]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ParsedMessage returns enough to render the textual body of a message. It is + // assumed the client already has other fields through MessageItem. + async ParsedMessage(msgID) { + const fn = "ParsedMessage"; + const paramTypes = [["int64"]]; + const returnTypes = [["ParsedMessage"]]; + const params = [msgID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageSubmit sends a message by submitting it the outgoing email queue. The + // message is sent to all addresses listed in the To, Cc and Bcc addresses, without + // Bcc message header. + // + // If a Sent mailbox is configured, messages are added to it after submitting + // to the delivery queue. + async MessageSubmit(m) { + const fn = "MessageSubmit"; + const paramTypes = [["SubmitMessage"]]; + const returnTypes = []; + const params = [m]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageMove moves messages to another mailbox. If the message is already in + // the mailbox an error is returned. + async MessageMove(messageIDs, mailboxID) { + const fn = "MessageMove"; + const paramTypes = [["[]", "int64"], ["int64"]]; + const returnTypes = []; + const params = [messageIDs, mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageDelete permanently deletes messages, without moving them to the Trash mailbox. + async MessageDelete(messageIDs) { + const fn = "MessageDelete"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [messageIDs]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsAdd adds flags, either system flags like \Seen or custom keywords. The + // flags should be lower-case, but will be converted and verified. + async FlagsAdd(messageIDs, flaglist) { + const fn = "FlagsAdd"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsClear clears flags, either system flags like \Seen or custom keywords. + async FlagsClear(messageIDs, flaglist) { + const fn = "FlagsClear"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxCreate creates a new mailbox. + async MailboxCreate(name) { + const fn = "MailboxCreate"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [name]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxDelete deletes a mailbox and all its messages. + async MailboxDelete(mailboxID) { + const fn = "MailboxDelete"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not + // its child mailboxes. + async MailboxEmpty(mailboxID) { + const fn = "MailboxEmpty"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox + // ID and its messages are unchanged. + async MailboxRename(mailboxID, newName) { + const fn = "MailboxRename"; + const paramTypes = [["int64"], ["string"]]; + const returnTypes = []; + const params = [mailboxID, newName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // CompleteRecipient returns autocomplete matches for a recipient, returning the + // matches, most recently used first, and whether this is the full list and further + // requests for longer prefixes aren't necessary. + async CompleteRecipient(search) { + const fn = "CompleteRecipient"; + const paramTypes = [["string"]]; + const returnTypes = [["[]", "string"], ["bool"]]; + const params = [search]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxSetSpecialUse sets the special use flags of a mailbox. + async MailboxSetSpecialUse(mb) { + const fn = "MailboxSetSpecialUse"; + const paramTypes = [["Mailbox"]]; + const returnTypes = []; + const params = [mb]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // SSETypes exists to ensure the generated API contains the types, for use in SSE events. + async SSETypes() { + const fn = "SSETypes"; + const paramTypes = []; + const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]]; + 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 { + 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 [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 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]; +})(); +// join elements in l with the results of calls to efn. efn can return +// HTMLElements, which cannot be inserted into the dom multiple times, hence the +// function. +const join = (l, efn) => { + const r = []; + const n = l.length; + for (let i = 0; i < n; i++) { + r.push(l[i]); + if (i < n - 1) { + r.push(efn()); + } + } + return r; +}; +// addLinks turns a line of text into alternating strings and links. Links that +// would end with interpunction followed by whitespace are returned with that +// interpunction moved to the next string instead. +const addLinks = (text) => { + // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. + const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const r = []; + while (text.length > 0) { + const l = re.exec(text); + if (!l) { + r.push(text); + break; + } + let s = text.substring(0, l.index); + let url = l[0]; + text = text.substring(l.index + url.length); + r.push(s); + // If URL ends with interpunction, and next character is whitespace or end, don't + // include the interpunction in the URL. + if (/[!),.:;>?]$/.test(url) && (!text || /^[ \t\r\n]/.test(text))) { + text = url.substring(url.length - 1) + text; + url = url.substring(0, url.length - 1); + } + r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + } + return r; +}; +// renderText turns text into a renderable element with ">" interpreted as quoted +// text (with different levels), and URLs replaced by links. +const renderText = (text) => { + return dom.div(text.split('\n').map(line => { + let q = 0; + for (const c of line) { + if (c == '>') { + q++; + } + else if (c !== ' ') { + break; + } + } + if (q == 0) { + return [addLinks(line), '\n']; + } + q = (q - 1) % 3 + 1; + return dom.div(dom._class('quoted' + q), addLinks(line)); + })); +}; +const displayName = (s) => { + // ../rfc/5322:1216 + // ../rfc/5322:1270 + // todo: need support for group addresses (eg "undisclosed recipients"). + // ../rfc/5322:697 + const specials = /[()<>\[\]:;@\\,."]/; + if (specials.test(s)) { + return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'; + } + return s; +}; +// format an address with both name and email address. +const formatAddress = (a) => { + let s = '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Name) { + s = displayName(a.Name) + ' ' + s; + } + return s; +}; +// returns an address with all available details, including unicode version if +// available. +const formatAddressFull = (a) => { + let s = ''; + if (a.Name) { + s = a.Name + ' '; + } + s += '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Domain.Unicode) { + s += ' (' + a.User + '@' + a.Domain.Unicode + ')'; + } + return s; +}; +// format just the name, or otherwies just the email address. +const formatAddressShort = (a) => { + if (a.Name) { + return a.Name; + } + return '<' + a.User + '@' + a.Domain.ASCII + '>'; +}; +// return just the email address. +const formatEmailASCII = (a) => { + return a.User + '@' + a.Domain.ASCII; +}; +const equalAddress = (a, b) => { + return (!a.User || !b.User || a.User === b.User) && a.Domain.ASCII === b.Domain.ASCII; +}; +// loadMsgheaderView loads the common message headers into msgheaderelem. +// if refineKeyword is set, labels are shown and a click causes a call to +// refineKeyword. +const loadMsgheaderView = (msgheaderelem, mi, refineKeyword) => { + const msgenv = mi.Envelope; + const received = mi.Message.Received; + const receivedlocal = new Date(received.getTime() - received.getTimezoneOffset() * 60 * 1000); + dom._kids(msgheaderelem, + // todo: make addresses clickable, start search (keep current mailbox if any) + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.To || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.CC || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.BCC || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + await refineKeyword(kw); + })) : []))))); +}; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const init = () => { + const mi = api.parser.MessageItem(messageItem); + let msgattachmentview = dom.div(); + if (mi.Attachments && mi.Attachments.length > 0) { + dom._kids(msgattachmentview, dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', join(mi.Attachments.map(a => a.Filename || '(unnamed)'), () => ', ')))); + } + const msgheaderview = dom.table(style({ marginBottom: '1ex', width: '100%' })); + loadMsgheaderView(msgheaderview, mi, null); + const l = window.location.pathname.split('/'); + const w = l[l.length - 1]; + let iframepath; + if (w === 'msgtext') { + iframepath = 'text'; + } + else if (w === 'msghtml') { + iframepath = 'html'; + } + else if (w === 'msghtmlexternal') { + iframepath = 'htmlexternal'; + } + else { + window.alert('Unknown message type ' + w); + return; + } + iframepath += '?sameorigin=true'; + let iframe; + const page = document.getElementById('page'); + dom._kids(page, dom.div(style({ backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc' }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), style({ border: '0', width: '100%', height: '100%' }), function load() { + // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. + iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; + if (window.location.hash === '#print') { + window.print(); + } + })); +}; +try { + init(); +} +catch (err) { + window.alert('Error: ' + (err.message || '(no message)')); +} diff --git a/webmail/msg.ts b/webmail/msg.ts new file mode 100644 index 0000000..9a7deae --- /dev/null +++ b/webmail/msg.ts @@ -0,0 +1,67 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +// Loaded from synchronous javascript. +declare let messageItem: api.MessageItem + +const init = () => { + const mi = api.parser.MessageItem(messageItem) + + let msgattachmentview = dom.div() + if (mi.Attachments && mi.Attachments.length > 0) { + dom._kids(msgattachmentview, + dom.div( + style({borderTop: '1px solid #ccc'}), + dom.div(dom._class('pad'), + 'Attachments: ', + join(mi.Attachments.map(a => a.Filename || '(unnamed)'), () => ', '), + ), + ) + ) + } + + const msgheaderview = dom.table(style({marginBottom: '1ex', width: '100%'})) + loadMsgheaderView(msgheaderview, mi, null) + + const l = window.location.pathname.split('/') + const w = l[l.length-1] + let iframepath: string + if (w === 'msgtext') { + iframepath = 'text' + } else if (w === 'msghtml') { + iframepath = 'html' + } else if (w === 'msghtmlexternal') { + iframepath = 'htmlexternal' + } else { + window.alert('Unknown message type '+w) + return + } + iframepath += '?sameorigin=true' + + let iframe: HTMLIFrameElement + const page = document.getElementById('page')! + dom._kids(page, + dom.div( + style({backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc'}), + msgheaderview, + msgattachmentview, + ), + iframe=dom.iframe( + attr.title('Message body.'), + attr.src(iframepath), + style({border: '0', width: '100%', height: '100%'}), + function load() { + // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. + iframe.style.height = iframe.contentDocument!.documentElement.scrollHeight+'px' + if (window.location.hash === '#print') { + window.print() + } + }, + ) + ) +} + +try { + init() +} catch (err) { + window.alert('Error: ' + ((err as any).message || '(no message)')) +} diff --git a/webmail/text.html b/webmail/text.html new file mode 100644 index 0000000..ea6104c --- /dev/null +++ b/webmail/text.html @@ -0,0 +1,25 @@ + + + + + + + + + +
Loading...
+ + + + + + + diff --git a/webmail/text.js b/webmail/text.js new file mode 100644 index 0000000..5f95101 --- /dev/null +++ b/webmail/text.js @@ -0,0 +1,933 @@ +"use strict"; +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY +var api; +(function (api) { + // Validation of "message From" domain. + let Validation; + (function (Validation) { + Validation[Validation["ValidationUnknown"] = 0] = "ValidationUnknown"; + Validation[Validation["ValidationStrict"] = 1] = "ValidationStrict"; + Validation[Validation["ValidationDMARC"] = 2] = "ValidationDMARC"; + Validation[Validation["ValidationRelaxed"] = 3] = "ValidationRelaxed"; + Validation[Validation["ValidationPass"] = 4] = "ValidationPass"; + Validation[Validation["ValidationNeutral"] = 5] = "ValidationNeutral"; + Validation[Validation["ValidationTemperror"] = 6] = "ValidationTemperror"; + Validation[Validation["ValidationPermerror"] = 7] = "ValidationPermerror"; + Validation[Validation["ValidationFail"] = 8] = "ValidationFail"; + Validation[Validation["ValidationSoftfail"] = 9] = "ValidationSoftfail"; + Validation[Validation["ValidationNone"] = 10] = "ValidationNone"; + })(Validation = api.Validation || (api.Validation = {})); + // AttachmentType is for filtering by attachment type. + let AttachmentType; + (function (AttachmentType) { + AttachmentType["AttachmentIndifferent"] = ""; + AttachmentType["AttachmentNone"] = "none"; + AttachmentType["AttachmentAny"] = "any"; + AttachmentType["AttachmentImage"] = "image"; + AttachmentType["AttachmentPDF"] = "pdf"; + AttachmentType["AttachmentArchive"] = "archive"; + AttachmentType["AttachmentSpreadsheet"] = "spreadsheet"; + AttachmentType["AttachmentDocument"] = "document"; + AttachmentType["AttachmentPresentation"] = "presentation"; + })(AttachmentType = api.AttachmentType || (api.AttachmentType = {})); + api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "Request": true, "SpecialUse": true, "SubmitMessage": true }; + api.stringsTypes = { "AttachmentType": true, "Localpart": true }; + api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; + api.types = { + "Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] }, + "Query": { "Name": "Query", "Docs": "", "Fields": [{ "Name": "OrderAsc", "Docs": "", "Typewords": ["bool"] }, { "Name": "Filter", "Docs": "", "Typewords": ["Filter"] }, { "Name": "NotFilter", "Docs": "", "Typewords": ["NotFilter"] }] }, + "Filter": { "Name": "Filter", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxChildrenIncluded", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Oldest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Newest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "SizeMin", "Docs": "", "Typewords": ["int64"] }, { "Name": "SizeMax", "Docs": "", "Typewords": ["int64"] }] }, + "NotFilter": { "Name": "NotFilter", "Docs": "", "Fields": [{ "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }] }, + "Page": { "Name": "Page", "Docs": "", "Fields": [{ "Name": "AnchorMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Count", "Docs": "", "Typewords": ["int32"] }, { "Name": "DestMessageID", "Docs": "", "Typewords": ["int64"] }] }, + "ParsedMessage": { "Name": "ParsedMessage", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }, { "Name": "Headers", "Docs": "", "Typewords": ["{}", "[]", "string"] }, { "Name": "Texts", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HasHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListReplyAddress", "Docs": "", "Typewords": ["nullable", "MessageAddress"] }] }, + "Part": { "Name": "Part", "Docs": "", "Fields": [{ "Name": "BoundaryOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "HeaderOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "BodyOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "EndOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "RawLineCount", "Docs": "", "Typewords": ["int64"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "MediaType", "Docs": "", "Typewords": ["string"] }, { "Name": "MediaSubType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentDescription", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTransferEncoding", "Docs": "", "Typewords": ["string"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["nullable", "Envelope"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Part"] }, { "Name": "Message", "Docs": "", "Typewords": ["nullable", "Part"] }] }, + "Envelope": { "Name": "Envelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, + "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, + "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, + "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, + "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, + "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, + "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, + "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, + "MessageItem": { "Name": "MessageItem", "Docs": "", "Fields": [{ "Name": "Message", "Docs": "", "Typewords": ["Message"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["MessageEnvelope"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "Attachment"] }, { "Name": "IsSigned", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsEncrypted", "Docs": "", "Typewords": ["bool"] }, { "Name": "FirstLine", "Docs": "", "Typewords": ["string"] }] }, + "Message": { "Name": "Message", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "CreateSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Expunged", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxOrigID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxDestinedID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked1", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked2", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked3", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLODomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MailFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "RcptToLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RcptToDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MsgFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromOrgDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLOValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "EHLOValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MailFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MsgFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "DKIMDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageHash", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "TrainedJunk", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "ParsedBuf", "Docs": "", "Typewords": ["nullable", "string"] }] }, + "MessageEnvelope": { "Name": "MessageEnvelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Attachment": { "Name": "Attachment", "Docs": "", "Fields": [{ "Name": "Path", "Docs": "", "Typewords": ["[]", "int32"] }, { "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }] }, + "EventViewChanges": { "Name": "EventViewChanges", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Changes", "Docs": "", "Typewords": ["[]", "[]", "any"] }] }, + "ChangeMsgAdd": { "Name": "ChangeMsgAdd", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageItem", "Docs": "", "Typewords": ["MessageItem"] }] }, + "Flags": { "Name": "Flags", "Docs": "", "Fields": [{ "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMsgRemove": { "Name": "ChangeMsgRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UIDs", "Docs": "", "Typewords": ["[]", "UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }] }, + "ChangeMsgFlags": { "Name": "ChangeMsgFlags", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Mask", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxRemove": { "Name": "ChangeMailboxRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }] }, + "ChangeMailboxAdd": { "Name": "ChangeMailboxAdd", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["Mailbox"] }] }, + "ChangeMailboxRename": { "Name": "ChangeMailboxRename", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "OldName", "Docs": "", "Typewords": ["string"] }, { "Name": "NewName", "Docs": "", "Typewords": ["string"] }, { "Name": "Flags", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxCounts": { "Name": "ChangeMailboxCounts", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "ChangeMailboxSpecialUse": { "Name": "ChangeMailboxSpecialUse", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "SpecialUse", "Docs": "", "Typewords": ["SpecialUse"] }] }, + "SpecialUse": { "Name": "SpecialUse", "Docs": "", "Fields": [{ "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMailboxKeywords": { "Name": "ChangeMailboxKeywords", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "UID": { "Name": "UID", "Docs": "", "Values": null }, + "ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null }, + "Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] }, + "AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] }, + "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, + }; + api.parser = { + Request: (v) => api.parse("Request", v), + Query: (v) => api.parse("Query", v), + Filter: (v) => api.parse("Filter", v), + NotFilter: (v) => api.parse("NotFilter", v), + Page: (v) => api.parse("Page", v), + ParsedMessage: (v) => api.parse("ParsedMessage", v), + Part: (v) => api.parse("Part", v), + Envelope: (v) => api.parse("Envelope", v), + Address: (v) => api.parse("Address", v), + MessageAddress: (v) => api.parse("MessageAddress", v), + Domain: (v) => api.parse("Domain", v), + SubmitMessage: (v) => api.parse("SubmitMessage", v), + File: (v) => api.parse("File", v), + ForwardAttachments: (v) => api.parse("ForwardAttachments", v), + Mailbox: (v) => api.parse("Mailbox", v), + EventStart: (v) => api.parse("EventStart", v), + DomainAddressConfig: (v) => api.parse("DomainAddressConfig", v), + EventViewErr: (v) => api.parse("EventViewErr", v), + EventViewReset: (v) => api.parse("EventViewReset", v), + EventViewMsgs: (v) => api.parse("EventViewMsgs", v), + MessageItem: (v) => api.parse("MessageItem", v), + Message: (v) => api.parse("Message", v), + MessageEnvelope: (v) => api.parse("MessageEnvelope", v), + Attachment: (v) => api.parse("Attachment", v), + EventViewChanges: (v) => api.parse("EventViewChanges", v), + ChangeMsgAdd: (v) => api.parse("ChangeMsgAdd", v), + Flags: (v) => api.parse("Flags", v), + ChangeMsgRemove: (v) => api.parse("ChangeMsgRemove", v), + ChangeMsgFlags: (v) => api.parse("ChangeMsgFlags", v), + ChangeMailboxRemove: (v) => api.parse("ChangeMailboxRemove", v), + ChangeMailboxAdd: (v) => api.parse("ChangeMailboxAdd", v), + ChangeMailboxRename: (v) => api.parse("ChangeMailboxRename", v), + ChangeMailboxCounts: (v) => api.parse("ChangeMailboxCounts", v), + ChangeMailboxSpecialUse: (v) => api.parse("ChangeMailboxSpecialUse", v), + SpecialUse: (v) => api.parse("SpecialUse", v), + ChangeMailboxKeywords: (v) => api.parse("ChangeMailboxKeywords", v), + UID: (v) => api.parse("UID", v), + ModSeq: (v) => api.parse("ModSeq", v), + Validation: (v) => api.parse("Validation", v), + AttachmentType: (v) => api.parse("AttachmentType", v), + Localpart: (v) => api.parse("Localpart", v), + }; + let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true }; + class Client { + 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 }); + } + // Token returns a token to use for an SSE connection. A token can only be used for + // a single SSE connection. Tokens are stored in memory for a maximum of 1 minute, + // with at most 10 unused tokens (the most recently created) per account. + async Token() { + const fn = "Token"; + const paramTypes = []; + const returnTypes = [["string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Requests sends a new request for an open SSE connection. Any currently active + // request for the connection will be canceled, but this is done asynchrously, so + // the SSE connection may still send results for the previous request. Callers + // should take care to ignore such results. If req.Cancel is set, no new request is + // started. + async Request(req) { + const fn = "Request"; + const paramTypes = [["Request"]]; + const returnTypes = []; + const params = [req]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ParsedMessage returns enough to render the textual body of a message. It is + // assumed the client already has other fields through MessageItem. + async ParsedMessage(msgID) { + const fn = "ParsedMessage"; + const paramTypes = [["int64"]]; + const returnTypes = [["ParsedMessage"]]; + const params = [msgID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageSubmit sends a message by submitting it the outgoing email queue. The + // message is sent to all addresses listed in the To, Cc and Bcc addresses, without + // Bcc message header. + // + // If a Sent mailbox is configured, messages are added to it after submitting + // to the delivery queue. + async MessageSubmit(m) { + const fn = "MessageSubmit"; + const paramTypes = [["SubmitMessage"]]; + const returnTypes = []; + const params = [m]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageMove moves messages to another mailbox. If the message is already in + // the mailbox an error is returned. + async MessageMove(messageIDs, mailboxID) { + const fn = "MessageMove"; + const paramTypes = [["[]", "int64"], ["int64"]]; + const returnTypes = []; + const params = [messageIDs, mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageDelete permanently deletes messages, without moving them to the Trash mailbox. + async MessageDelete(messageIDs) { + const fn = "MessageDelete"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [messageIDs]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsAdd adds flags, either system flags like \Seen or custom keywords. The + // flags should be lower-case, but will be converted and verified. + async FlagsAdd(messageIDs, flaglist) { + const fn = "FlagsAdd"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsClear clears flags, either system flags like \Seen or custom keywords. + async FlagsClear(messageIDs, flaglist) { + const fn = "FlagsClear"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxCreate creates a new mailbox. + async MailboxCreate(name) { + const fn = "MailboxCreate"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [name]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxDelete deletes a mailbox and all its messages. + async MailboxDelete(mailboxID) { + const fn = "MailboxDelete"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not + // its child mailboxes. + async MailboxEmpty(mailboxID) { + const fn = "MailboxEmpty"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox + // ID and its messages are unchanged. + async MailboxRename(mailboxID, newName) { + const fn = "MailboxRename"; + const paramTypes = [["int64"], ["string"]]; + const returnTypes = []; + const params = [mailboxID, newName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // CompleteRecipient returns autocomplete matches for a recipient, returning the + // matches, most recently used first, and whether this is the full list and further + // requests for longer prefixes aren't necessary. + async CompleteRecipient(search) { + const fn = "CompleteRecipient"; + const paramTypes = [["string"]]; + const returnTypes = [["[]", "string"], ["bool"]]; + const params = [search]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxSetSpecialUse sets the special use flags of a mailbox. + async MailboxSetSpecialUse(mb) { + const fn = "MailboxSetSpecialUse"; + const paramTypes = [["Mailbox"]]; + const returnTypes = []; + const params = [mb]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // SSETypes exists to ensure the generated API contains the types, for use in SSE events. + async SSETypes() { + const fn = "SSETypes"; + const paramTypes = []; + const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]]; + 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 { + 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 [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 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]; +})(); +// join elements in l with the results of calls to efn. efn can return +// HTMLElements, which cannot be inserted into the dom multiple times, hence the +// function. +const join = (l, efn) => { + const r = []; + const n = l.length; + for (let i = 0; i < n; i++) { + r.push(l[i]); + if (i < n - 1) { + r.push(efn()); + } + } + return r; +}; +// addLinks turns a line of text into alternating strings and links. Links that +// would end with interpunction followed by whitespace are returned with that +// interpunction moved to the next string instead. +const addLinks = (text) => { + // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. + const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const r = []; + while (text.length > 0) { + const l = re.exec(text); + if (!l) { + r.push(text); + break; + } + let s = text.substring(0, l.index); + let url = l[0]; + text = text.substring(l.index + url.length); + r.push(s); + // If URL ends with interpunction, and next character is whitespace or end, don't + // include the interpunction in the URL. + if (/[!),.:;>?]$/.test(url) && (!text || /^[ \t\r\n]/.test(text))) { + text = url.substring(url.length - 1) + text; + url = url.substring(0, url.length - 1); + } + r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + } + return r; +}; +// renderText turns text into a renderable element with ">" interpreted as quoted +// text (with different levels), and URLs replaced by links. +const renderText = (text) => { + return dom.div(text.split('\n').map(line => { + let q = 0; + for (const c of line) { + if (c == '>') { + q++; + } + else if (c !== ' ') { + break; + } + } + if (q == 0) { + return [addLinks(line), '\n']; + } + q = (q - 1) % 3 + 1; + return dom.div(dom._class('quoted' + q), addLinks(line)); + })); +}; +const displayName = (s) => { + // ../rfc/5322:1216 + // ../rfc/5322:1270 + // todo: need support for group addresses (eg "undisclosed recipients"). + // ../rfc/5322:697 + const specials = /[()<>\[\]:;@\\,."]/; + if (specials.test(s)) { + return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'; + } + return s; +}; +// format an address with both name and email address. +const formatAddress = (a) => { + let s = '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Name) { + s = displayName(a.Name) + ' ' + s; + } + return s; +}; +// returns an address with all available details, including unicode version if +// available. +const formatAddressFull = (a) => { + let s = ''; + if (a.Name) { + s = a.Name + ' '; + } + s += '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Domain.Unicode) { + s += ' (' + a.User + '@' + a.Domain.Unicode + ')'; + } + return s; +}; +// format just the name, or otherwies just the email address. +const formatAddressShort = (a) => { + if (a.Name) { + return a.Name; + } + return '<' + a.User + '@' + a.Domain.ASCII + '>'; +}; +// return just the email address. +const formatEmailASCII = (a) => { + return a.User + '@' + a.Domain.ASCII; +}; +const equalAddress = (a, b) => { + return (!a.User || !b.User || a.User === b.User) && a.Domain.ASCII === b.Domain.ASCII; +}; +// loadMsgheaderView loads the common message headers into msgheaderelem. +// if refineKeyword is set, labels are shown and a click causes a call to +// refineKeyword. +const loadMsgheaderView = (msgheaderelem, mi, refineKeyword) => { + const msgenv = mi.Envelope; + const received = mi.Message.Received; + const receivedlocal = new Date(received.getTime() - received.getTimezoneOffset() * 60 * 1000); + dom._kids(msgheaderelem, + // todo: make addresses clickable, start search (keep current mailbox if any) + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.To || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.CC || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.BCC || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + await refineKeyword(kw); + })) : []))))); +}; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const init = async () => { + const pm = api.parser.ParsedMessage(parsedMessage); + dom._kids(document.body, dom.div(dom._class('pad', 'mono'), style({ whiteSpace: 'pre-wrap' }), join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({ margin: '2ex 0' }))))); +}; +init() + .catch((err) => { + window.alert('Error: ' + (err.message || '(no message)')); +}); diff --git a/webmail/text.ts b/webmail/text.ts new file mode 100644 index 0000000..c9e61fd --- /dev/null +++ b/webmail/text.ts @@ -0,0 +1,19 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +// Loaded from synchronous javascript. +declare let parsedMessage: api.ParsedMessage + +const init = async () => { + const pm = api.parser.ParsedMessage(parsedMessage) + dom._kids(document.body, + dom.div(dom._class('pad', 'mono'), + style({whiteSpace: 'pre-wrap'}), + join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({margin: '2ex 0'}))), + ) + ) +} + +init() +.catch((err) => { + window.alert('Error: ' + ((err as any).message || '(no message)')) +}) diff --git a/webmail/view.go b/webmail/view.go new file mode 100644 index 0000000..47e3f60 --- /dev/null +++ b/webmail/view.go @@ -0,0 +1,1789 @@ +package webmail + +// todo: may want to add some json omitempty tags to MessageItem, or Message to reduce json size, or just have smaller types that send only the fields that are needed. + +import ( + "compress/gzip" + "context" + cryptrand "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "reflect" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/exp/slices" + + "github.com/mjl-/bstore" + "github.com/mjl-/sherpa" + + "github.com/mjl-/mox/dns" + "github.com/mjl-/mox/message" + "github.com/mjl-/mox/metrics" + "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/moxio" + "github.com/mjl-/mox/smtp" + "github.com/mjl-/mox/store" +) + +// Request is a request to an SSE connection to send messages, either for a new +// view, to continue with an existing view, or to a cancel an ongoing request. +type Request struct { + ID int64 + + SSEID int64 // SSE connection. + + // To indicate a request is a continuation (more results) of the previous view. + // Echoed in events, client checks if it is getting results for the latest request. + ViewID int64 + + // If set, this request and its view are canceled. A new view must be started. + Cancel bool + + Query Query + Page Page +} + +// Query is a request for messages that match filters, in a given order. +type Query struct { + OrderAsc bool // Order by received ascending or desending. + Filter Filter + NotFilter NotFilter +} + +// AttachmentType is for filtering by attachment type. +type AttachmentType string + +const ( + AttachmentIndifferent AttachmentType = "" + AttachmentNone AttachmentType = "none" + AttachmentAny AttachmentType = "any" + AttachmentImage AttachmentType = "image" // png, jpg, gif, ... + AttachmentPDF AttachmentType = "pdf" + AttachmentArchive AttachmentType = "archive" // zip files, tgz, ... + AttachmentSpreadsheet AttachmentType = "spreadsheet" // ods, xlsx, ... + AttachmentDocument AttachmentType = "document" // odt, docx, ... + AttachmentPresentation AttachmentType = "presentation" // odp, pptx, ... +) + +// Filter selects the messages to return. Fields that are set must all match, +// for slices each element by match ("and"). +type Filter struct { + // If -1, then all mailboxes except Trash/Junk/Rejects. Otherwise, only active if > 0. + MailboxID int64 + + // If true, also submailboxes are included in the search. + MailboxChildrenIncluded bool + + // In case client doesn't know mailboxes and their IDs yet. Only used during sse + // connection setup, where it is turned into a MailboxID. Filtering only looks at + // MailboxID. + MailboxName string + + Words []string // Case insensitive substring match for each string. + From []string + To []string // Including Cc and Bcc. + Oldest *time.Time + Newest *time.Time + Subject []string + Attachments AttachmentType + Labels []string + Headers [][2]string // Header values can be empty, it's a check if the header is present, regardless of value. + SizeMin int64 + SizeMax int64 +} + +// NotFilter matches messages that don't match these fields. +type NotFilter struct { + Words []string + From []string + To []string + Subject []string + Attachments AttachmentType + Labels []string +} + +// Page holds pagination parameters for a request. +type Page struct { + // Start returning messages after this ID, if > 0. For pagination, fetching the + // next set of messages. + AnchorMessageID int64 + + // Number of messages to return, must be >= 1, we never return more than 10000 for + // one request. + Count int + + // If > 0, return messages until DestMessageID is found. More than Count messages + // can be returned. For long-running searches, it may take a while before this + // message if found. + DestMessageID int64 +} + +// todo: MessageAddress and MessageEnvelope into message.Address and message.Envelope. + +// MessageAddress is like message.Address, but with a dns.Domain, with unicode name +// included. +type MessageAddress struct { + Name string // Free-form name for display in mail applications. + User string // Localpart, encoded. + Domain dns.Domain +} + +// MessageEnvelope is like message.Envelope, as used in message.Part, but including +// unicode host names for IDNA names. +type MessageEnvelope struct { + // todo: should get sherpadoc to understand type embeds and embed the non-MessageAddress fields from message.Envelope. + Date time.Time + Subject string + From []MessageAddress + Sender []MessageAddress + ReplyTo []MessageAddress + To []MessageAddress + CC []MessageAddress + BCC []MessageAddress + InReplyTo string + MessageID string +} + +// MessageItem is sent by queries, it has derived information analyzed from +// message.Part, made for the needs of the message items in the message list. +// messages. +type MessageItem struct { + Message store.Message // Without ParsedBuf and MsgPrefix, for size. + Envelope MessageEnvelope + Attachments []Attachment + IsSigned bool + IsEncrypted bool + FirstLine string // Of message body, for showing as preview. +} + +// ParsedMessage has more parsed/derived information about a message, intended +// for rendering the (contents of the) message. Information from MessageItem is +// not duplicated. +type ParsedMessage struct { + ID int64 + Part message.Part + Headers map[string][]string + + // Text parts, can be empty. + Texts []string + + // Whether there is an HTML part. The webclient renders HTML message parts through + // an iframe and a separate request with strict CSP headers to prevent script + // execution and loading of external resources, which isn't possible when loading + // in iframe with inline HTML because not all browsers support the iframe csp + // attribute. + HasHTML bool + + ListReplyAddress *MessageAddress // From List-Post. + + // Information used by MessageItem, not exported in this type. + envelope MessageEnvelope + attachments []Attachment + isSigned bool + isEncrypted bool + firstLine string +} + +// EventStart is the first message sent on an SSE connection, giving the client +// basic data to populate its UI. After this event, messages will follow quickly in +// an EventViewMsgs event. +type EventStart struct { + SSEID int64 + LoginAddress MessageAddress + Addresses []MessageAddress + DomainAddressConfigs map[string]DomainAddressConfig // ASCII domain to address config. + MailboxName string + Mailboxes []store.Mailbox +} + +// DomainAddressConfig has the address (localpart) configuration for a domain, so +// the webmail client can decide if an address matches the addresses of the +// account. +type DomainAddressConfig struct { + LocalpartCatchallSeparator string // Can be empty. + LocalpartCaseSensitive bool +} + +// EventViewMsgs contains messages for a view, possibly a continuation of an +// earlier list of messages. +type EventViewMsgs struct { + ViewID int64 + RequestID int64 + + MessageItems []MessageItem // If empty, this was the last message for the request. + ParsedMessage *ParsedMessage // If set, will match the target page.DestMessageID from the request. + + // If set, there are no more messages in this view at this moment. Messages can be + // added, typically via Change messages, e.g. for new deliveries. + ViewEnd bool +} + +// EventViewErr indicates an error during a query for messages. The request is +// aborted, no more request-related messages will be sent until the next request. +type EventViewErr struct { + ViewID int64 + RequestID int64 + Err string // To be displayed in client. + err error // Original message, for checking against context.Canceled. +} + +// EventViewReset indicates that a request for the next set of messages in a few +// could not be fulfilled, e.g. because the anchor message does not exist anymore. +// The client should clear its list of messages. This can happen before +// EventViewMsgs events are sent. +type EventViewReset struct { + ViewID int64 + RequestID int64 +} + +// EventViewChanges contain one or more changes relevant for the client, either +// with new mailbox total/unseen message counts, or messages added/removed/modified +// (flags) for the current view. +type EventViewChanges struct { + ViewID int64 + Changes [][2]any // The first field of [2]any is a string, the second of the Change types below. +} + +// ChangeMsgAdd adds a new message to the view. +type ChangeMsgAdd struct { + store.ChangeAddUID + MessageItem MessageItem +} + +// ChangeMsgRemove removes one or more messages from the view. +type ChangeMsgRemove struct { + store.ChangeRemoveUIDs +} + +// ChangeMsgFlags updates flags for one message. +type ChangeMsgFlags struct { + store.ChangeFlags +} + +// ChangeMailboxRemove indicates a mailbox was removed, including all its messages. +type ChangeMailboxRemove struct { + store.ChangeRemoveMailbox +} + +// ChangeMailboxAdd indicates a new mailbox was added, initially without any messages. +type ChangeMailboxAdd struct { + Mailbox store.Mailbox +} + +// ChangeMailboxRename indicates a mailbox was renamed. Its ID stays the same. +// It could be under a new parent. +type ChangeMailboxRename struct { + store.ChangeRenameMailbox +} + +// ChangeMailboxCounts set new total and unseen message counts for a mailbox. +type ChangeMailboxCounts struct { + store.ChangeMailboxCounts +} + +// ChangeMailboxSpecialUse has updated special-use flags for a mailbox. +type ChangeMailboxSpecialUse struct { + store.ChangeMailboxSpecialUse +} + +// ChangeMailboxKeywords has an updated list of keywords for a mailbox, e.g. after +// a message was added with a keyword that wasn't in the mailbox yet. +type ChangeMailboxKeywords struct { + store.ChangeMailboxKeywords +} + +// View holds the information about the returned data for a query. It is used to +// determine whether mailbox changes should be sent to the client, we only send +// addition/removal/flag-changes of messages that are in view, or would extend it +// if the view is at the end of the results. +type view struct { + Request Request + + // Last message we sent to the client. We use it to decide if a newly delivered + // message is within the view and the client should get a notification. + LastMessage store.Message + + // If set, the last message in the query view has been sent. There is no need to do + // another query, it will not return more data. Used to decide if an event for a + // new message should be sent. + End bool + + // Whether message must or must not match mailboxIDs. + matchMailboxIDs bool + // Mailboxes to match, can be multiple, for matching children. If empty, there is + // no filter on mailboxes. + mailboxIDs map[int64]bool +} + +// sses tracks all sse connections, and access to them. +var sses = struct { + sync.Mutex + gen int64 + m map[int64]sse +}{m: map[int64]sse{}} + +// sse represents an sse connection. +type sse struct { + ID int64 // Also returned in EventStart and used in Request to identify the request. + AccountName string // Used to check the authenticated user has access to the SSE connection. + Request chan Request // Goroutine will receive requests from here, coming from API calls. +} + +// called by the goroutine when the connection is closed or breaks. +func (sse sse) unregister() { + sses.Lock() + defer sses.Unlock() + delete(sses.m, sse.ID) + + // Drain any pending requests, preventing blocked goroutines from API calls. + for { + select { + case <-sse.Request: + default: + return + } + } +} + +func sseRegister(accountName string) sse { + sses.Lock() + defer sses.Unlock() + sses.gen++ + v := sse{sses.gen, accountName, make(chan Request, 1)} + sses.m[v.ID] = v + return v +} + +// sseGet returns a reference to an existing connection if it exists and user +// has access. +func sseGet(id int64, accountName string) (sse, bool) { + sses.Lock() + defer sses.Unlock() + s := sses.m[id] + if s.AccountName != accountName { + return sse{}, false + } + return s, true +} + +// ssetoken is a temporary token that has not yet been used to start an SSE +// connection. Created by Token, consumed by a new SSE connection. +type ssetoken struct { + token string // Uniquely generated. + accName string + address string // Address used to authenticate in call that created the token. + validUntil time.Time +} + +// ssetokens maintains unused tokens. We have just one, but it's a type so we +// can define methods. +type ssetokens struct { + sync.Mutex + accountTokens map[string][]ssetoken // Account to max 10 most recent tokens, from old to new. + tokens map[string]ssetoken // Token to details, for finding account for a token. +} + +var sseTokens = ssetokens{ + accountTokens: map[string][]ssetoken{}, + tokens: map[string]ssetoken{}, +} + +// xgenerate creates and saves a new token. It ensures no more than 10 tokens +// per account exist, removing old ones if needed. +func (x *ssetokens) xgenerate(ctx context.Context, accName, address string) string { + buf := make([]byte, 16) + _, err := cryptrand.Read(buf) + xcheckf(ctx, err, "generating token") + st := ssetoken{base64.RawURLEncoding.EncodeToString(buf), accName, address, time.Now().Add(time.Minute)} + + x.Lock() + defer x.Unlock() + n := len(x.accountTokens[accName]) + if n >= 10 { + for _, ost := range x.accountTokens[accName][:n-9] { + delete(x.tokens, ost.token) + } + copy(x.accountTokens[accName], x.accountTokens[accName][n-9:]) + x.accountTokens[accName] = x.accountTokens[accName][:9] + } + x.accountTokens[accName] = append(x.accountTokens[accName], st) + x.tokens[st.token] = st + return st.token +} + +// check verifies a token, and consumes it if valid. +func (x *ssetokens) check(token string) (string, string, bool, error) { + x.Lock() + defer x.Unlock() + + st, ok := x.tokens[token] + if !ok { + return "", "", false, nil + } + delete(x.tokens, token) + if i := slices.Index(x.accountTokens[st.accName], st); i < 0 { + return "", "", false, errors.New("internal error, could not find token in account") + } else { + copy(x.accountTokens[st.accName][i:], x.accountTokens[st.accName][i+1:]) + x.accountTokens[st.accName] = x.accountTokens[st.accName][:len(x.accountTokens[st.accName])-1] + if len(x.accountTokens[st.accName]) == 0 { + delete(x.accountTokens, st.accName) + } + } + if time.Now().After(st.validUntil) { + return "", "", false, nil + } + return st.accName, st.address, true, nil +} + +// ioErr is panicked on i/o errors in serveEvents and handled in a defer. +type ioErr struct { + err error +} + +// serveEvents serves an SSE connection. Authentication is done through a query +// string parameter "token", a one-time-use token returned by the Token API call. +func serveEvents(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + log.Error("internal error: ResponseWriter not a http.Flusher") + http.Error(w, "500 - internal error - cannot sync to http connection", 500) + return + } + + q := r.URL.Query() + token := q.Get("token") + if token == "" { + http.Error(w, "400 - bad request - missing credentials", http.StatusBadRequest) + return + } + accName, address, ok, err := sseTokens.check(token) + if err != nil { + http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "400 - bad request - bad token", http.StatusBadRequest) + return + } + + // We can simulate a slow SSE connection. It seems firefox doesn't slow down + // incoming responses with its slow-network similation. + var waitMin, waitMax time.Duration + waitMinMsec := q.Get("waitMinMsec") + waitMaxMsec := q.Get("waitMaxMsec") + if waitMinMsec != "" && waitMaxMsec != "" { + if v, err := strconv.ParseInt(waitMinMsec, 10, 64); err != nil { + http.Error(w, "400 - bad request - parsing waitMinMsec: "+err.Error(), http.StatusBadRequest) + return + } else { + waitMin = time.Duration(v) * time.Millisecond + } + + if v, err := strconv.ParseInt(waitMaxMsec, 10, 64); err != nil { + http.Error(w, "400 - bad request - parsing waitMaxMsec: "+err.Error(), http.StatusBadRequest) + return + } else { + waitMax = time.Duration(v) * time.Millisecond + } + } + + // Parse the request with initial mailbox/search criteria. + var req Request + dec := json.NewDecoder(strings.NewReader(q.Get("request"))) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, "400 - bad request - bad request query string parameter: "+err.Error(), http.StatusBadRequest) + return + } else if req.Page.Count <= 0 { + http.Error(w, "400 - bad request - request cannot have Page.Count 0", http.StatusBadRequest) + return + } + + var writer *eventWriter + + metricSSEConnections.Inc() + defer metricSSEConnections.Dec() + + // Below here, error handling cause through xcheckf, which panics with + // *sherpa.Error, after which we send an error event to the client. We can also get + // an *ioErr when the connection is broken. + defer func() { + x := recover() + if x == nil { + return + } + if err, ok := x.(*sherpa.Error); ok { + writer.xsendEvent(ctx, log, "fatalErr", err.Message) + } else if _, ok := x.(ioErr); ok { + return + } else { + log.WithContext(ctx).Error("serveEvents panic", mlog.Field("err", x)) + debug.PrintStack() + metrics.PanicInc("webmail") + panic(x) + } + }() + + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + + // 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) + if gz { + h.Set("Content-Encoding", "gzip") + out, _ = gzip.NewWriterLevel(w, gzip.BestSpeed) + } else { + out = nopFlusher{w} + } + out = httpFlusher{out, flusher} + + // We'll be writing outgoing SSE events through writer. + writer = newEventWriter(out, waitMin, waitMax) + defer writer.close() + + // Fetch initial data. + acc, err := store.OpenAccount(accName) + xcheckf(ctx, err, "open account") + defer func() { + err := acc.Close() + log.Check(err, "closing account") + }() + comm := store.RegisterComm(acc) + defer comm.Unregister() + + // List addresses that the client can use to send email from. + accConf, _ := acc.Conf() + loginAddr, err := smtp.ParseAddress(address) + xcheckf(ctx, err, "parsing login address") + _, _, dest, err := mox.FindAccount(loginAddr.Localpart, loginAddr.Domain, false) + xcheckf(ctx, err, "looking up destination for login address") + loginName := accConf.FullName + if dest.FullName != "" { + loginName = dest.FullName + } + loginAddress := MessageAddress{Name: loginName, User: loginAddr.Localpart.String(), Domain: loginAddr.Domain} + var addresses []MessageAddress + for a, dest := range accConf.Destinations { + name := dest.FullName + if name == "" { + name = accConf.FullName + } + var ma MessageAddress + if strings.HasPrefix(a, "@") { + dom, err := dns.ParseDomain(a[1:]) + xcheckf(ctx, err, "parsing destination address for account") + ma = MessageAddress{Domain: dom} + } else { + addr, err := smtp.ParseAddress(a) + xcheckf(ctx, err, "parsing destination address for account") + ma = MessageAddress{Name: name, User: addr.Localpart.String(), Domain: addr.Domain} + } + addresses = append(addresses, ma) + } + + // We implicitly start a query. We use the reqctx for the transaction, because the + // transaction is passed to the query, which can be canceled. + reqctx, reqctxcancel := context.WithCancel(ctx) + defer func() { + // We also cancel in cancelDrain later on, but there is a brief window where the + // context wouldn't be canceled. + if reqctxcancel != nil { + reqctxcancel() + reqctxcancel = nil + } + }() + + // qtx is kept around during connection initialization, until we pass it off to the + // goroutine that starts querying for messages. + var qtx *bstore.Tx + defer func() { + if qtx != nil { + err := qtx.Rollback() + log.Check(err, "rolling back") + } + }() + + var mbl []store.Mailbox + + // We only take the rlock when getting the tx. + acc.WithRLock(func() { + // Now a read-only transaction we'll use during the query. + qtx, err = acc.DB.Begin(reqctx, false) + xcheckf(ctx, err, "begin transaction") + + mbl, err = bstore.QueryTx[store.Mailbox](qtx).List() + xcheckf(ctx, err, "list mailboxes") + }) + + // Find the designated mailbox if a mailbox name is set, or there are no filters at all. + var zerofilter Filter + var zeronotfilter NotFilter + var mailbox store.Mailbox + var mailboxPrefixes []string + var matchMailboxes bool + mailboxIDs := map[int64]bool{} + mailboxName := req.Query.Filter.MailboxName + if mailboxName != "" || reflect.DeepEqual(req.Query.Filter, zerofilter) && reflect.DeepEqual(req.Query.NotFilter, zeronotfilter) { + if mailboxName == "" { + mailboxName = "Inbox" + } + + var inbox store.Mailbox + for _, e := range mbl { + if e.Name == mailboxName { + mailbox = e + } + if e.Name == "Inbox" { + inbox = e + } + } + if mailbox.ID == 0 { + mailbox = inbox + } + if mailbox.ID == 0 { + xcheckf(ctx, errors.New("inbox not found"), "setting initial mailbox") + } + req.Query.Filter.MailboxID = mailbox.ID + req.Query.Filter.MailboxName = "" + mailboxPrefixes = []string{mailbox.Name + "/"} + matchMailboxes = true + mailboxIDs[mailbox.ID] = true + } else { + matchMailboxes, mailboxIDs, mailboxPrefixes = xprepareMailboxIDs(ctx, qtx, req.Query.Filter, accConf.RejectsMailbox) + } + if req.Query.Filter.MailboxChildrenIncluded { + xgatherMailboxIDs(ctx, qtx, mailboxIDs, mailboxPrefixes) + } + + // todo: write a last-event-id based on modseq? if last-event-id is present, we would have to send changes to mailboxes, messages, hopefully reducing the amount of data sent. + + sse := sseRegister(acc.Name) + defer sse.unregister() + + // Per-domain localpart config so webclient can decide if an address belongs to the account. + domainAddressConfigs := map[string]DomainAddressConfig{} + for _, a := range addresses { + dom, _ := mox.Conf.Domain(a.Domain) + domainAddressConfigs[a.Domain.ASCII] = DomainAddressConfig{dom.LocalpartCatchallSeparator, dom.LocalpartCaseSensitive} + } + + // Write first event, allowing client to fill its UI with mailboxes. + start := EventStart{sse.ID, loginAddress, addresses, domainAddressConfigs, mailbox.Name, mbl} + writer.xsendEvent(ctx, log, "start", start) + + // The goroutine doing the querying will send messages on these channels, which + // result in an event being written on the SSE connection. + viewMsgsc := make(chan EventViewMsgs) + viewErrc := make(chan EventViewErr) + viewResetc := make(chan EventViewReset) + donec := make(chan int64) // When request is done. + + // Start a view, it determines if we send a change to the client. And start an + // implicit query for messages, we'll send the messages to the client which can + // fill its ui with messages. + v := view{req, store.Message{}, false, matchMailboxes, mailboxIDs} + go viewRequestTx(reqctx, log, acc, qtx, v, viewMsgsc, viewErrc, viewResetc, donec) + qtx = nil // viewRequestTx closes qtx + + // When canceling a query, we must drain its messages until it says it is done. + // Otherwise the sending goroutine would hang indefinitely on a channel send. + cancelDrain := func() { + if reqctxcancel != nil { + // Cancel the goroutine doing the querying. + reqctxcancel() + reqctx = nil + reqctxcancel = nil + } else { + return + } + + // Drain events until done. + for { + select { + case <-viewMsgsc: + case <-viewErrc: + case <-viewResetc: + case <-donec: + return + } + } + } + + // If we stop and a query is in progress, we must drain the channel it will send on. + defer cancelDrain() + + // Changes broadcasted by other connections on this account. If applicable for the + // connection/view, we send events. + xprocessChanges := func(changes []store.Change) { + taggedChanges := [][2]any{} + + // We get a transaction first time we need it. + var xtx *bstore.Tx + defer func() { + if xtx != nil { + err := xtx.Rollback() + log.Check(err, "rolling back transaction") + } + }() + ensureTx := func() error { + if xtx != nil { + return nil + } + acc.RLock() + defer acc.RUnlock() + var err error + xtx, err = acc.DB.Begin(ctx, false) + return err + } + // This getmsg will now only be called mailboxID+UID, not with messageID set. + // todo jmap: change store.Change* to include MessageID's? would mean duplication of information resulting in possible mismatch. + getmsg := func(messageID int64, mailboxID int64, uid store.UID) (store.Message, error) { + if err := ensureTx(); err != nil { + return store.Message{}, fmt.Errorf("transaction: %v", err) + } + return bstore.QueryTx[store.Message](xtx).FilterEqual("Expunged", false).FilterNonzero(store.Message{MailboxID: mailboxID, UID: uid}).Get() + } + + // Return uids that are within range in view. Because the end has been reached, or + // because the UID is not after the last message. + xchangedUIDs := func(mailboxID int64, uids []store.UID) (changedUIDs []store.UID) { + uidsAny := make([]any, len(uids)) + for i, uid := range uids { + uidsAny[i] = uid + } + err := ensureTx() + xcheckf(ctx, err, "transaction") + q := bstore.QueryTx[store.Message](xtx) + q.FilterNonzero(store.Message{MailboxID: mailboxID}) + q.FilterEqual("UID", uidsAny...) + err = q.ForEach(func(m store.Message) error { + if v.inRange(m) { + changedUIDs = append(changedUIDs, m.UID) + } + return nil + }) + xcheckf(ctx, err, "fetching messages for change") + return changedUIDs + } + + // Forward changes that are relevant to the current view. + for _, change := range changes { + switch c := change.(type) { + case store.ChangeAddUID: + if ok, err := v.matches(log, acc, true, 0, c.MailboxID, c.UID, c.Flags, c.Keywords, getmsg); err != nil { + xcheckf(ctx, err, "matching new message against view") + } else if !ok { + continue + } + m, err := getmsg(0, c.MailboxID, c.UID) + xcheckf(ctx, err, "get message") + state := msgState{acc: acc} + mi, err := messageItem(log, m, &state) + state.clear() + xcheckf(ctx, err, "make messageitem") + taggedChanges = append(taggedChanges, [2]any{"ChangeMsgAdd", ChangeMsgAdd{c, mi}}) + + // If message extends the view, store it as such. + if !v.Request.Query.OrderAsc && m.Received.Before(v.LastMessage.Received) || v.Request.Query.OrderAsc && m.Received.After(v.LastMessage.Received) { + v.LastMessage = m + } + + case store.ChangeRemoveUIDs: + // We do a quick filter over changes, not sending UID updates for unselected + // mailboxes or when the message is outside the range of the view. But we still may + // send messages that don't apply to the filter. If client doesn't recognize the + // messages, that's fine. + if !v.matchesMailbox(c.MailboxID) { + continue + } + changedUIDs := xchangedUIDs(c.MailboxID, c.UIDs) + if len(changedUIDs) == 0 { + continue + } + ch := ChangeMsgRemove{c} + ch.UIDs = changedUIDs + taggedChanges = append(taggedChanges, [2]any{"ChangeMsgRemove", ch}) + + case store.ChangeFlags: + // As with ChangeRemoveUIDs above, we send more changes than strictly needed. + if !v.matchesMailbox(c.MailboxID) { + continue + } + changedUIDs := xchangedUIDs(c.MailboxID, []store.UID{c.UID}) + if len(changedUIDs) == 0 { + continue + } + ch := ChangeMsgFlags{c} + ch.UID = changedUIDs[0] + taggedChanges = append(taggedChanges, [2]any{"ChangeMsgFlags", ch}) + + case store.ChangeRemoveMailbox: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxRemove", ChangeMailboxRemove{c}}) + + case store.ChangeAddMailbox: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxAdd", ChangeMailboxAdd{c.Mailbox}}) + + case store.ChangeRenameMailbox: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxRename", ChangeMailboxRename{c}}) + + case store.ChangeMailboxCounts: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxCounts", ChangeMailboxCounts{c}}) + + case store.ChangeMailboxSpecialUse: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxSpecialUse", ChangeMailboxSpecialUse{c}}) + + case store.ChangeMailboxKeywords: + taggedChanges = append(taggedChanges, [2]any{"ChangeMailboxKeywords", ChangeMailboxKeywords{c}}) + + case store.ChangeAddSubscription: + // Webmail does not care about subscriptions. + + default: + panic(fmt.Sprintf("missing case for change %T", c)) + } + } + + if len(taggedChanges) > 0 { + viewChanges := EventViewChanges{v.Request.ViewID, taggedChanges} + writer.xsendEvent(ctx, log, "viewChanges", viewChanges) + } + } + + timer := time.NewTimer(5 * time.Minute) // For keepalives. + defer timer.Stop() + for { + if writer.wrote { + timer.Reset(5 * time.Minute) + writer.wrote = false + } + + pending := comm.Pending + if reqctx != nil { + pending = nil + } + + select { + case <-mox.Shutdown.Done(): + writer.xsendEvent(ctx, log, "fatalErr", "server is shutting down") + // Work around go vet, it doesn't see defer cancelDrain. + if reqctxcancel != nil { + reqctxcancel() + } + return + + case <-timer.C: + _, err := fmt.Fprintf(out, ": keepalive\n\n") + if err != nil { + log.Errorx("write keepalive", err) + // Work around go vet, it doesn't see defer cancelDrain. + if reqctxcancel != nil { + reqctxcancel() + } + return + } + out.Flush() + writer.wrote = true + + case vm := <-viewMsgsc: + if vm.RequestID != v.Request.ID || vm.ViewID != v.Request.ViewID { + panic(fmt.Sprintf("received msgs for view,request id %d,%d instead of %d,%d", vm.ViewID, vm.RequestID, v.Request.ViewID, v.Request.ID)) + } + if vm.ViewEnd { + v.End = true + } + if len(vm.MessageItems) > 0 { + v.LastMessage = vm.MessageItems[len(vm.MessageItems)-1].Message + } + writer.xsendEvent(ctx, log, "viewMsgs", vm) + + case ve := <-viewErrc: + if ve.RequestID != v.Request.ID || ve.ViewID != v.Request.ViewID { + panic(fmt.Sprintf("received err for view,request id %d,%d instead of %d,%d", ve.ViewID, ve.RequestID, v.Request.ViewID, v.Request.ID)) + } + if errors.Is(ve.err, context.Canceled) || moxio.IsClosed(ve.err) { + // Work around go vet, it doesn't see defer cancelDrain. + if reqctxcancel != nil { + reqctxcancel() + } + return + } + writer.xsendEvent(ctx, log, "viewErr", ve) + + case vr := <-viewResetc: + if vr.RequestID != v.Request.ID || vr.ViewID != v.Request.ViewID { + panic(fmt.Sprintf("received reset for view,request id %d,%d instead of %d,%d", vr.ViewID, vr.RequestID, v.Request.ViewID, v.Request.ID)) + } + writer.xsendEvent(ctx, log, "viewReset", vr) + + case id := <-donec: + if id != v.Request.ID { + panic(fmt.Sprintf("received done for request id %d instead of %d", id, v.Request.ID)) + } + if reqctxcancel != nil { + reqctxcancel() + } + reqctx = nil + reqctxcancel = nil + + case req := <-sse.Request: + if reqctx != nil { + cancelDrain() + } + if req.Cancel { + v = view{req, store.Message{}, false, false, nil} + continue + } + + reqctx, reqctxcancel = context.WithCancel(ctx) + + stop := func() (stop bool) { + // rtx is handed off viewRequestTx below, but we must clean it up in case of errors. + var rtx *bstore.Tx + var err error + defer func() { + if rtx != nil { + err = rtx.Rollback() + log.Check(err, "rolling back transaction") + } + }() + acc.WithRLock(func() { + rtx, err = acc.DB.Begin(reqctx, false) + }) + if err != nil { + reqctxcancel() + reqctx = nil + reqctxcancel = nil + + if errors.Is(err, context.Canceled) { + return true + } + err := fmt.Errorf("begin transaction: %v", err) + viewErr := EventViewErr{v.Request.ViewID, v.Request.ID, err.Error(), err} + writer.xsendEvent(ctx, log, "viewErr", viewErr) + return false + } + + // Reset view state for new query. + if req.ViewID != v.Request.ViewID { + matchMailboxes, mailboxIDs, mailboxPrefixes := xprepareMailboxIDs(ctx, rtx, req.Query.Filter, accConf.RejectsMailbox) + if req.Query.Filter.MailboxChildrenIncluded { + xgatherMailboxIDs(ctx, rtx, mailboxIDs, mailboxPrefixes) + } + v = view{req, store.Message{}, false, matchMailboxes, mailboxIDs} + } else { + v.Request = req + } + go viewRequestTx(reqctx, log, acc, rtx, v, viewMsgsc, viewErrc, viewResetc, donec) + rtx = nil + return false + }() + if stop { + return + } + + case <-pending: + xprocessChanges(comm.Get()) + + case <-ctx.Done(): + // Work around go vet, it doesn't see defer cancelDrain. + if reqctxcancel != nil { + reqctxcancel() + } + return + } + } +} + +// xprepareMailboxIDs prepare the first half of filters for mailboxes, based on +// f.MailboxID (-1 is special). matchMailboxes indicates whether the IDs in +// mailboxIDs must or must not match. mailboxPrefixes is for use with +// xgatherMailboxIDs to gather children of the mailboxIDs. +func xprepareMailboxIDs(ctx context.Context, tx *bstore.Tx, f Filter, rejectsMailbox string) (matchMailboxes bool, mailboxIDs map[int64]bool, mailboxPrefixes []string) { + matchMailboxes = true + mailboxIDs = map[int64]bool{} + if f.MailboxID == -1 { + matchMailboxes = false + // Add the trash, junk and account rejects mailbox. + err := bstore.QueryTx[store.Mailbox](tx).ForEach(func(mb store.Mailbox) error { + if mb.Trash || mb.Junk || mb.Name == rejectsMailbox { + mailboxPrefixes = append(mailboxPrefixes, mb.Name+"/") + mailboxIDs[mb.ID] = true + } + return nil + }) + xcheckf(ctx, err, "finding trash/junk/rejects mailbox") + } else if f.MailboxID > 0 { + mb := store.Mailbox{ID: f.MailboxID} + err := tx.Get(&mb) + xcheckf(ctx, err, "get mailbox") + mailboxIDs[f.MailboxID] = true + mailboxPrefixes = []string{mb.Name + "/"} + } + return +} + +// xgatherMailboxIDs adds all mailboxes with a prefix matching any of +// mailboxPrefixes to mailboxIDs, to expand filtering to children of mailboxes. +func xgatherMailboxIDs(ctx context.Context, tx *bstore.Tx, mailboxIDs map[int64]bool, mailboxPrefixes []string) { + // Gather more mailboxes to filter on, based on mailboxPrefixes. + if len(mailboxPrefixes) == 0 { + return + } + err := bstore.QueryTx[store.Mailbox](tx).ForEach(func(mb store.Mailbox) error { + for _, p := range mailboxPrefixes { + if strings.HasPrefix(mb.Name, p) { + mailboxIDs[mb.ID] = true + break + } + } + return nil + }) + xcheckf(ctx, err, "gathering mailboxes") +} + +// matchesMailbox returns whether a mailbox matches the view. +func (v view) matchesMailbox(mailboxID int64) bool { + return len(v.mailboxIDs) == 0 || v.matchMailboxIDs && v.mailboxIDs[mailboxID] || !v.matchMailboxIDs && !v.mailboxIDs[mailboxID] +} + +// inRange returns whether m is within the range for the view, whether a change for +// this message should be sent to the client so it can update its state. +func (v view) inRange(m store.Message) bool { + return v.End || !v.Request.Query.OrderAsc && !m.Received.Before(v.LastMessage.Received) || v.Request.Query.OrderAsc && !m.Received.After(v.LastMessage.Received) +} + +// matches checks if the message, identified by either messageID or mailboxID+UID, +// is in the current "view" (i.e. passing the filters, and if checkRange is set +// also if within the range of sent messages based on sort order and the last seen +// message). getmsg retrieves the message, which may be necessary depending on the +// active filters. Used to determine if a store.Change with a new message should be +// sent, and for the destination and anchor messages in view requests. +func (v view) matches(log *mlog.Log, acc *store.Account, checkRange bool, messageID int64, mailboxID int64, uid store.UID, flags store.Flags, keywords []string, getmsg func(int64, int64, store.UID) (store.Message, error)) (match bool, rerr error) { + var m store.Message + ensureMessage := func() bool { + if m.ID == 0 && rerr == nil { + m, rerr = getmsg(messageID, mailboxID, uid) + } + return rerr == nil + } + + q := v.Request.Query + + // Warning: Filters must be kept in sync between queryMessage and view.matches. + + // Check filters. + if len(v.mailboxIDs) > 0 && (!ensureMessage() || v.matchMailboxIDs && !v.mailboxIDs[m.MailboxID] || !v.matchMailboxIDs && v.mailboxIDs[m.MailboxID]) { + return false, rerr + } + // note: anchorMessageID is not relevant for matching. + flagfilter := q.flagFilterFn() + if flagfilter != nil && !flagfilter(flags, keywords) { + return false, rerr + } + + if q.Filter.Oldest != nil && (!ensureMessage() || m.Received.Before(*q.Filter.Oldest)) { + return false, rerr + } + if q.Filter.Newest != nil && (!ensureMessage() || !m.Received.Before(*q.Filter.Newest)) { + return false, rerr + } + + if q.Filter.SizeMin > 0 && (!ensureMessage() || m.Size < q.Filter.SizeMin) { + return false, rerr + } + if q.Filter.SizeMax > 0 && (!ensureMessage() || m.Size > q.Filter.SizeMax) { + return false, rerr + } + + state := msgState{acc: acc} + defer func() { + if rerr == nil && state.err != nil { + rerr = state.err + } + state.clear() + }() + + attachmentFilter := q.attachmentFilterFn(log, acc, &state) + if attachmentFilter != nil && (!ensureMessage() || !attachmentFilter(m)) { + return false, rerr + } + + envFilter := q.envFilterFn(log, &state) + if envFilter != nil && (!ensureMessage() || !envFilter(m)) { + return false, rerr + } + + headerFilter := q.headerFilterFn(log, &state) + if headerFilter != nil && (!ensureMessage() || !headerFilter(m)) { + return false, rerr + } + + wordsFilter := q.wordsFilterFn(log, &state) + if wordsFilter != nil && (!ensureMessage() || !wordsFilter(m)) { + return false, rerr + } + + // Now check that we are either within the sorting order, or "last" was sent. + if !checkRange || v.End || ensureMessage() && v.inRange(m) { + return true, rerr + } + return false, rerr +} + +type msgResp struct { + err error // If set, an error happened and fields below are not set. + reset bool // If set, the anchor message does not exist (anymore?) and we are sending messages from the start, fields below not set. + viewEnd bool // If set, the last message for the view was seen, no more should be requested, fields below not set. + mi MessageItem // If none of the cases above apply, the message that was found matching the query. + pm *ParsedMessage // If m was the target page.DestMessageID, or this is the first match, this is the parsed message of mi. +} + +// viewRequestTx executes a request (query with filters, pagination) by +// launching a new goroutine with queryMessages, receiving results as msgResp, +// and sending Event* to the SSE connection. +// +// It always closes tx. +func viewRequestTx(ctx context.Context, log *mlog.Log, acc *store.Account, tx *bstore.Tx, v view, msgc chan EventViewMsgs, errc chan EventViewErr, resetc chan EventViewReset, donec chan int64) { + defer func() { + err := tx.Rollback() + log.Check(err, "rolling back query transaction") + + donec <- v.Request.ID + + x := recover() // Should not happen, but don't take program down if it does. + if x != nil { + log.WithContext(ctx).Error("viewRequestTx panic", mlog.Field("err", x)) + debug.PrintStack() + metrics.PanicInc("webmail-request") + } + }() + + var msgitems []MessageItem // Gathering for 300ms, then flushing. + var parsedMessage *ParsedMessage + var viewEnd bool + + var immediate bool // No waiting, flush immediate. + t := time.NewTimer(300 * time.Millisecond) + defer t.Stop() + + sendViewMsgs := func(force bool) { + if len(msgitems) == 0 && !force { + return + } + + immediate = false + msgc <- EventViewMsgs{v.Request.ViewID, v.Request.ID, msgitems, parsedMessage, viewEnd} + msgitems = nil + parsedMessage = nil + t.Reset(300 * time.Millisecond) + } + + // todo: should probably rewrite code so we don't start yet another goroutine, but instead handle the query responses directly (through a struct that keeps state?) in the sse connection goroutine. + + mrc := make(chan msgResp, 1) + go queryMessages(ctx, log, acc, tx, v, mrc) + + for { + select { + case mr, ok := <-mrc: + if !ok { + sendViewMsgs(false) + // Empty message list signals this query is done. + msgc <- EventViewMsgs{v.Request.ViewID, v.Request.ID, nil, nil, false} + return + } + if mr.err != nil { + sendViewMsgs(false) + errc <- EventViewErr{v.Request.ViewID, v.Request.ID, mr.err.Error(), mr.err} + return + } + if mr.reset { + resetc <- EventViewReset{v.Request.ViewID, v.Request.ID} + continue + } + if mr.viewEnd { + viewEnd = true + sendViewMsgs(true) + return + } + + msgitems = append(msgitems, mr.mi) + if mr.pm != nil { + parsedMessage = mr.pm + } + if immediate { + sendViewMsgs(true) + } + + case <-t.C: + if len(msgitems) == 0 { + // Nothing to send yet. We'll send immediately when the next message comes in. + immediate = true + } else { + sendViewMsgs(false) + } + } + } +} + +// queryMessages executes a query, with filter, pagination, destination message id +// to fetch (the message that the client had in view and wants to display again). +// It sends on msgc, with several types of messages: errors, whether the view is +// reset due to missing AnchorMessageID, and when the end of the view was reached +// and/or for a message. +func queryMessages(ctx context.Context, log *mlog.Log, acc *store.Account, tx *bstore.Tx, v view, mrc chan msgResp) { + defer func() { + x := recover() // Should not happen, but don't take program down if it does. + if x != nil { + log.WithContext(ctx).Error("queryMessages panic", mlog.Field("err", x)) + debug.PrintStack() + mrc <- msgResp{err: fmt.Errorf("query failed")} + metrics.PanicInc("webmail-query") + } + + close(mrc) + }() + + query := v.Request.Query + page := v.Request.Page + + // Warning: Filters must be kept in sync between queryMessage and view.matches. + + checkMessage := func(id int64) (valid bool, rerr error) { + m := store.Message{ID: id} + err := tx.Get(&m) + if err == bstore.ErrAbsent || err == nil && m.Expunged { + return false, nil + } else if err != nil { + return false, err + } else { + return v.matches(log, acc, false, m.ID, m.MailboxID, m.UID, m.Flags, m.Keywords, func(int64, int64, store.UID) (store.Message, error) { + return m, nil + }) + } + } + + // Check if AnchorMessageID exists and matches filter. If not, we will reset the view. + if page.AnchorMessageID > 0 { + // Check if message exists and (still) matches the filter. + // todo: if AnchorMessageID exists but no longer matches the filter, we are resetting the view, but could handle it more gracefully in the future. if the message is in a different mailbox, we cannot query as efficiently, we'll have to read through more messages. + if valid, err := checkMessage(page.AnchorMessageID); err != nil { + mrc <- msgResp{err: fmt.Errorf("querying AnchorMessageID: %v", err)} + return + } else if !valid { + mrc <- msgResp{reset: true} + page.AnchorMessageID = 0 + } + } + + // Check if page.DestMessageID exists and matches filter. If not, we will ignore + // it instead of continuing to send message till the end of the view. + if page.DestMessageID > 0 { + if valid, err := checkMessage(page.DestMessageID); err != nil { + mrc <- msgResp{err: fmt.Errorf("querying requested message: %v", err)} + return + } else if !valid { + page.DestMessageID = 0 + } + } + + // todo optimize: we would like to have more filters directly on the database if they can use an index. eg if there is a keyword filter and no mailbox filter. + + q := bstore.QueryTx[store.Message](tx) + q.FilterEqual("Expunged", false) + if len(v.mailboxIDs) > 0 { + if len(v.mailboxIDs) == 1 && v.matchMailboxIDs { + // Should result in fast indexed query. + for mbID := range v.mailboxIDs { + q.FilterNonzero(store.Message{MailboxID: mbID}) + } + } else { + idsAny := make([]any, 0, len(v.mailboxIDs)) + for mbID := range v.mailboxIDs { + idsAny = append(idsAny, mbID) + } + if v.matchMailboxIDs { + q.FilterEqual("MailboxID", idsAny...) + } else { + q.FilterNotEqual("MailboxID", idsAny...) + } + } + } + + // If we are looking for an anchor, keep skipping message early (cheaply) until we've seen it. + if page.AnchorMessageID > 0 { + var seen = false + q.FilterFn(func(m store.Message) bool { + if seen { + return true + } + seen = m.ID == page.AnchorMessageID + return false + }) + } + + // We may be added filters the the query below. The FilterFn signature does not + // implement reporting errors, or anything else, just a bool. So when making the + // filter functions, we give them a place to store parsed message state, and an + // error. We check the error during and after query execution. + state := msgState{acc: acc} + defer state.clear() + + flagfilter := query.flagFilterFn() + if flagfilter != nil { + q.FilterFn(func(m store.Message) bool { + return flagfilter(m.Flags, m.Keywords) + }) + } + + if query.Filter.Oldest != nil { + q.FilterGreaterEqual("Received", *query.Filter.Oldest) + } + if query.Filter.Newest != nil { + q.FilterLessEqual("Received", *query.Filter.Newest) + } + + if query.Filter.SizeMin > 0 { + q.FilterGreaterEqual("Size", query.Filter.SizeMin) + } + if query.Filter.SizeMax > 0 { + q.FilterLessEqual("Size", query.Filter.SizeMax) + } + + attachmentFilter := query.attachmentFilterFn(log, acc, &state) + if attachmentFilter != nil { + q.FilterFn(attachmentFilter) + } + + envFilter := query.envFilterFn(log, &state) + if envFilter != nil { + q.FilterFn(envFilter) + } + + headerFilter := query.headerFilterFn(log, &state) + if headerFilter != nil { + q.FilterFn(headerFilter) + } + + wordsFilter := query.wordsFilterFn(log, &state) + if wordsFilter != nil { + q.FilterFn(wordsFilter) + } + + if query.OrderAsc { + q.SortAsc("Received") + } else { + q.SortDesc("Received") + } + found := page.DestMessageID <= 0 + end := true + have := 0 + err := q.ForEach(func(m store.Message) error { + // Check for an error in one of the filters, propagate it. + if state.err != nil { + return state.err + } + + if have >= page.Count && found || have > 10000 { + end = false + return bstore.StopForEach + } + var pm *ParsedMessage + if m.ID == page.DestMessageID || page.DestMessageID == 0 && have == 0 && page.AnchorMessageID == 0 { + found = true + xpm, err := parsedMessage(log, m, &state, true, false) + if err != nil { + return fmt.Errorf("parsing message %d: %v", m.ID, err) + } + pm = &xpm + } + mi, err := messageItem(log, m, &state) + if err != nil { + return fmt.Errorf("making messageitem for message %d: %v", m.ID, err) + } + mrc <- msgResp{mi: mi, pm: pm} + have++ + return nil + }) + // Check for an error in one of the filters again. Check in ForEach would not + // trigger if the last message has the error. + if err == nil && state.err != nil { + err = state.err + } + if err != nil { + mrc <- msgResp{err: fmt.Errorf("querying messages: %v", err)} + return + } + if end { + mrc <- msgResp{viewEnd: true} + } +} + +// While checking the filters on a message, we may need to get more message +// details as each filter passes. We check the filters that need the basic +// information first, and load and cache more details for the next filters. +// msgState holds parsed details for a message, it is updated while filtering, +// with more information or reset for a next message. +type msgState struct { + acc *store.Account // Never changes during lifetime. + err error // Once set, doesn't get cleared. + m store.Message + part *message.Part // Will be without Reader when msgr is nil. + msgr *store.MsgReader +} + +func (ms *msgState) clear() { + if ms.msgr != nil { + ms.msgr.Close() + ms.msgr = nil + } + *ms = msgState{acc: ms.acc, err: ms.err} +} + +func (ms *msgState) ensureMsg(m store.Message) { + if m.ID != ms.m.ID { + ms.clear() + } + ms.m = m +} + +func (ms *msgState) ensurePart(m store.Message, withMsgReader bool) bool { + ms.ensureMsg(m) + + if ms.err == nil { + if ms.part == nil { + if m.ParsedBuf == nil { + ms.err = fmt.Errorf("message %d not parsed", m.ID) + return false + } + var p message.Part + if err := json.Unmarshal(m.ParsedBuf, &p); err != nil { + ms.err = fmt.Errorf("load part for message %d: %w", m.ID, err) + return false + } + ms.part = &p + } + if withMsgReader && ms.msgr == nil { + ms.msgr = ms.acc.MessageReader(m) + ms.part.SetReaderAt(ms.msgr) + } + } + return ms.part != nil +} + +// flagFilterFn returns a function that applies the flag/keyword/"label"-related +// filters for a query. A nil function is returned if there are no flags to filter +// on. +func (q Query) flagFilterFn() func(store.Flags, []string) bool { + labels := map[string]bool{} + for _, k := range q.Filter.Labels { + labels[k] = true + } + for _, k := range q.NotFilter.Labels { + labels[k] = false + } + + if len(labels) == 0 { + return nil + } + + var mask, flags store.Flags + systemflags := map[string][]*bool{ + `\answered`: {&mask.Answered, &flags.Answered}, + `\flagged`: {&mask.Flagged, &flags.Flagged}, + `\deleted`: {&mask.Deleted, &flags.Deleted}, + `\seen`: {&mask.Seen, &flags.Seen}, + `\draft`: {&mask.Draft, &flags.Draft}, + `$junk`: {&mask.Junk, &flags.Junk}, + `$notjunk`: {&mask.Notjunk, &flags.Notjunk}, + `$forwarded`: {&mask.Forwarded, &flags.Forwarded}, + `$phishing`: {&mask.Phishing, &flags.Phishing}, + `$mdnsent`: {&mask.MDNSent, &flags.MDNSent}, + } + keywords := map[string]bool{} + for k, v := range labels { + k = strings.ToLower(k) + if mf, ok := systemflags[k]; ok { + *mf[0] = true + *mf[1] = v + } else { + keywords[k] = v + } + } + return func(msgFlags store.Flags, msgKeywords []string) bool { + var f store.Flags + if f.Set(mask, msgFlags) != flags { + return false + } + for k, v := range keywords { + if slices.Contains(msgKeywords, k) != v { + return false + } + } + return true + } +} + +// attachmentFilterFn returns a function that filters for the attachment-related +// filter from the query. A nil function is returned if there are attachment +// filters. +func (q Query) attachmentFilterFn(log *mlog.Log, acc *store.Account, state *msgState) func(m store.Message) bool { + if q.Filter.Attachments == AttachmentIndifferent && q.NotFilter.Attachments == AttachmentIndifferent { + return nil + } + + return func(m store.Message) bool { + if !state.ensurePart(m, false) { + return false + } + types, err := attachmentTypes(log, m, state) + if err != nil { + state.err = err + return false + } + return (q.Filter.Attachments == AttachmentIndifferent || types[q.Filter.Attachments]) && (q.NotFilter.Attachments == AttachmentIndifferent || !types[q.NotFilter.Attachments]) + } +} + +var attachmentMimetypes = map[string]AttachmentType{ + "application/pdf": AttachmentPDF, + "application/zip": AttachmentArchive, + "application/x-rar-compressed": AttachmentArchive, + "application/vnd.oasis.opendocument.spreadsheet": AttachmentSpreadsheet, + "application/vnd.ms-excel": AttachmentSpreadsheet, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": AttachmentSpreadsheet, + "application/vnd.oasis.opendocument.text": AttachmentDocument, + "application/vnd.oasis.opendocument.presentation": AttachmentPresentation, + "application/vnd.ms-powerpoint": AttachmentPresentation, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": AttachmentPresentation, +} +var attachmentExtensions = map[string]AttachmentType{ + ".pdf": AttachmentPDF, + ".zip": AttachmentArchive, + ".tar": AttachmentArchive, + ".tgz": AttachmentArchive, + ".tar.gz": AttachmentArchive, + ".tbz2": AttachmentArchive, + ".tar.bz2": AttachmentArchive, + ".tar.lz": AttachmentArchive, + ".tlz": AttachmentArchive, + ".tar.xz": AttachmentArchive, + ".txz": AttachmentArchive, + ".tar.zst": AttachmentArchive, + ".tar.lz4": AttachmentArchive, + ".7z": AttachmentArchive, + ".rar": AttachmentArchive, + ".ods": AttachmentSpreadsheet, + ".xls": AttachmentSpreadsheet, + ".xlsx": AttachmentSpreadsheet, + ".odt": AttachmentDocument, + ".doc": AttachmentDocument, + ".docx": AttachmentDocument, + ".odp": AttachmentPresentation, + ".ppt": AttachmentPresentation, + ".pptx": AttachmentPresentation, +} + +func attachmentTypes(log *mlog.Log, m store.Message, state *msgState) (map[AttachmentType]bool, error) { + types := map[AttachmentType]bool{} + + pm, err := parsedMessage(log, m, state, false, false) + if err != nil { + return nil, fmt.Errorf("parsing message for attachments: %w", err) + } + for _, a := range pm.attachments { + if a.Part.MediaType == "IMAGE" { + types[AttachmentImage] = true + continue + } + mt := strings.ToLower(a.Part.MediaType + "/" + a.Part.MediaSubType) + if t, ok := attachmentMimetypes[mt]; ok { + types[t] = true + } else if ext := filepath.Ext(a.Part.ContentTypeParams["name"]); ext != "" { + if t, ok := attachmentExtensions[strings.ToLower(ext)]; ok { + types[t] = true + } else { + continue + } + } + } + + if len(types) == 0 { + types[AttachmentNone] = true + } else { + types[AttachmentAny] = true + } + return types, nil +} + +// envFilterFn returns a filter function for the "envelope" headers ("envelope" as +// used by IMAP, i.e. basic message headers from/to/subject, an unfortunate name +// clash with SMTP envelope) for the query. A nil function is returned if no +// filtering is needed. +func (q Query) envFilterFn(log *mlog.Log, state *msgState) func(m store.Message) bool { + if len(q.Filter.From) == 0 && len(q.Filter.To) == 0 && len(q.Filter.Subject) == 0 && len(q.NotFilter.From) == 0 && len(q.NotFilter.To) == 0 && len(q.NotFilter.Subject) == 0 { + return nil + } + + lower := func(l []string) []string { + if len(l) == 0 { + return nil + } + r := make([]string, len(l)) + for i, s := range l { + r[i] = strings.ToLower(s) + } + return r + } + + filterSubject := lower(q.Filter.Subject) + notFilterSubject := lower(q.NotFilter.Subject) + filterFrom := lower(q.Filter.From) + notFilterFrom := lower(q.NotFilter.From) + filterTo := lower(q.Filter.To) + notFilterTo := lower(q.NotFilter.To) + + return func(m store.Message) bool { + if !state.ensurePart(m, false) { + return false + } + + var env message.Envelope + if state.part.Envelope != nil { + env = *state.part.Envelope + } + + if len(filterSubject) > 0 || len(notFilterSubject) > 0 { + subject := strings.ToLower(env.Subject) + for _, s := range filterSubject { + if !strings.Contains(subject, s) { + return false + } + } + for _, s := range notFilterSubject { + if strings.Contains(subject, s) { + return false + } + } + } + + contains := func(textLower []string, l []message.Address, all bool) bool { + next: + for _, s := range textLower { + for _, a := range l { + name := strings.ToLower(a.Name) + addr := strings.ToLower(fmt.Sprintf("<%s@%s>", a.User, a.Host)) + if strings.Contains(name, s) || strings.Contains(addr, s) { + if !all { + return true + } + continue next + } + } + if all { + return false + } + } + return all + } + + if len(filterFrom) > 0 && !contains(filterFrom, env.From, true) { + return false + } + if len(notFilterFrom) > 0 && contains(notFilterFrom, env.From, false) { + return false + } + if len(filterTo) > 0 || len(notFilterTo) > 0 { + to := append(append(append([]message.Address{}, env.To...), env.CC...), env.BCC...) + if len(filterTo) > 0 && !contains(filterTo, to, true) { + return false + } + if len(notFilterTo) > 0 && contains(notFilterTo, to, false) { + return false + } + } + return true + } +} + +// headerFilterFn returns a function that filters for the header filters in the +// query. A nil function is returned if there are no header filters. +func (q Query) headerFilterFn(log *mlog.Log, state *msgState) func(m store.Message) bool { + if len(q.Filter.Headers) == 0 { + return nil + } + + lowerValues := make([]string, len(q.Filter.Headers)) + for i, t := range q.Filter.Headers { + lowerValues[i] = strings.ToLower(t[1]) + } + + return func(m store.Message) bool { + if !state.ensurePart(m, true) { + return false + } + hdr, err := state.part.Header() + if err != nil { + state.err = fmt.Errorf("reading header for message %d: %w", m.ID, err) + return false + } + + next: + for i, t := range q.Filter.Headers { + k := t[0] + v := lowerValues[i] + l := hdr.Values(k) + if v == "" && len(l) > 0 { + continue + } + for _, e := range l { + if strings.Contains(strings.ToLower(e), v) { + continue next + } + } + return false + } + return true + } +} + +// wordFiltersFn returns a function that applies the word filters of the query. A +// nil function is returned when query does not contain a word filter. +func (q Query) wordsFilterFn(log *mlog.Log, state *msgState) func(m store.Message) bool { + if len(q.Filter.Words) == 0 && len(q.NotFilter.Words) == 0 { + return nil + } + + ws := store.PrepareWordSearch(q.Filter.Words, q.NotFilter.Words) + + return func(m store.Message) bool { + if !state.ensurePart(m, true) { + return false + } + + if ok, err := ws.MatchPart(log, state.part, true); err != nil { + state.err = fmt.Errorf("searching for words in message %d: %w", m.ID, err) + return false + } else { + return ok + } + } +} diff --git a/webmail/view_test.go b/webmail/view_test.go new file mode 100644 index 0000000..b0ee2d4 --- /dev/null +++ b/webmail/view_test.go @@ -0,0 +1,440 @@ +package webmail + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "testing" + "time" + + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/store" +) + +func TestView(t *testing.T) { + os.RemoveAll("../testdata/webmail/data") + mox.Context = ctxbg + mox.ConfigStaticPath = "../testdata/webmail/mox.conf" + mox.MustLoadConfig(true, false) + switchDone := store.Switchboard() + defer close(switchDone) + + acc, err := store.OpenAccount("mjl") + tcheck(t, err, "open account") + err = acc.SetPassword("test1234") + tcheck(t, err, "set password") + defer func() { + err := acc.Close() + xlog.Check(err, "closing account") + }() + + api := Webmail{maxMessageSize: 1024 * 1024} + reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}} + ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo) + + api.MailboxCreate(ctx, "Lists/Go/Nuts") + + var zerom store.Message + var ( + inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0} + inboxFlags = &testmsg{"Inbox", store.Flags{Seen: true}, []string{"testlabel"}, msgAltRel, zerom, 0} // With flags, and larger. + listsMinimal = &testmsg{"Lists", store.Flags{}, nil, msgMinimal, zerom, 0} + listsGoNutsMinimal = &testmsg{"Lists/Go/Nuts", store.Flags{}, nil, msgMinimal, zerom, 0} + trashMinimal = &testmsg{"Trash", store.Flags{}, nil, msgMinimal, zerom, 0} + junkMinimal = &testmsg{"Trash", store.Flags{}, nil, msgMinimal, zerom, 0} + ) + var testmsgs = []*testmsg{inboxMinimal, inboxFlags, listsMinimal, listsGoNutsMinimal, trashMinimal, junkMinimal} + for _, tm := range testmsgs { + tdeliver(t, acc, tm) + } + + // Token + tokens := []string{} + for i := 0; i < 20; i++ { + tokens = append(tokens, api.Token(ctx)) + } + // Only last 10 tokens are still valid and around, checked below. + + // Request + tneedError(t, func() { api.Request(ctx, Request{ID: 1, Cancel: true}) }) // Zero/invalid SSEID. + + // We start an actual HTTP server to easily get a body we can do blocking reads on. + // With a httptest.ResponseRecorder, it's a bit more work to parse SSE events as + // they come in. + server := httptest.NewServer(http.HandlerFunc(Handler(1024 * 1024))) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + tcheck(t, err, "parsing server url") + _, port, err := net.SplitHostPort(serverURL.Host) + tcheck(t, err, "parsing host port in server url") + eventsURL := fmt.Sprintf("http://%s/events", net.JoinHostPort("localhost", port)) + + request := Request{ + Page: Page{Count: 10}, + } + requestJSON, err := json.Marshal(request) + tcheck(t, err, "marshal request as json") + + testFail := func(method, path string, expStatusCode int) { + t.Helper() + req, err := http.NewRequest(method, path, nil) + tcheck(t, err, "making request") + resp, err := http.DefaultClient.Do(req) + tcheck(t, err, "http transaction") + resp.Body.Close() + if resp.StatusCode != expStatusCode { + t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expStatusCode) + } + } + + testFail("POST", eventsURL+"?token="+tokens[0]+"&request="+string(requestJSON), http.StatusMethodNotAllowed) // Must be GET. + testFail("GET", eventsURL, http.StatusBadRequest) // Missing token. + testFail("GET", eventsURL+"?token="+tokens[0]+"&request="+string(requestJSON), http.StatusBadRequest) // Bad (old) token. + testFail("GET", eventsURL+"?token="+tokens[len(tokens)-5]+"&request=bad", http.StatusBadRequest) // Bad request. + + // Start connection for testing and filters below. + req, err := http.NewRequest("GET", eventsURL+"?token="+tokens[len(tokens)-1]+"&request="+string(requestJSON), nil) + tcheck(t, err, "making request") + resp, err := http.DefaultClient.Do(req) + tcheck(t, err, "http transaction") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, http.StatusOK) + } + + evr := eventReader{t, bufio.NewReader(resp.Body), resp.Body} + var start EventStart + evr.Get("start", &start) + var viewMsgs EventViewMsgs + evr.Get("viewMsgs", &viewMsgs) + tcompare(t, len(viewMsgs.MessageItems), 2) + tcompare(t, viewMsgs.ViewEnd, true) + + var inbox, archive, lists store.Mailbox + for _, mb := range start.Mailboxes { + if mb.Archive { + archive = mb + } else if mb.Name == start.MailboxName { + inbox = mb + } else if mb.Name == "Lists" { + lists = mb + } + } + + // Can only use a token once. + testFail("GET", eventsURL+"?token="+tokens[len(tokens)-1]+"&request=bad", http.StatusBadRequest) + + // Check a few initial query/page combinations. + testConn := func(token, more string, request Request, check func(EventStart, eventReader)) { + t.Helper() + + reqJSON, err := json.Marshal(request) + tcheck(t, err, "marshal request json") + req, err := http.NewRequest("GET", eventsURL+"?token="+token+more+"&request="+string(reqJSON), nil) + tcheck(t, err, "making request") + resp, err := http.DefaultClient.Do(req) + tcheck(t, err, "http transaction") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, http.StatusOK) + } + + xevr := eventReader{t, bufio.NewReader(resp.Body), resp.Body} + var xstart EventStart + xevr.Get("start", &xstart) + check(start, xevr) + } + + // Connection with waitMinMsec/waitMaxMsec, just exercising code path. + waitReq := Request{ + Page: Page{Count: 10}, + } + testConn(api.Token(ctx), "&waitMinMsec=1&waitMaxMsec=2", waitReq, func(start EventStart, evr eventReader) { + var vm EventViewMsgs + evr.Get("viewMsgs", &vm) + tcompare(t, len(vm.MessageItems), 2) + }) + + // Connection with DestMessageID. + destMsgReq := Request{ + Query: Query{ + Filter: Filter{MailboxID: inbox.ID}, + }, + Page: Page{DestMessageID: inboxFlags.ID, Count: 10}, + } + testConn(tokens[len(tokens)-3], "", destMsgReq, func(start EventStart, evr eventReader) { + var vm EventViewMsgs + evr.Get("viewMsgs", &vm) + tcompare(t, len(vm.MessageItems), 2) + tcompare(t, vm.ParsedMessage.ID, destMsgReq.Page.DestMessageID) + }) + // todo: destmessageid past count, needs large mailbox + + // Connection with missing DestMessageID, still fine. + badDestMsgReq := Request{ + Query: Query{ + Filter: Filter{MailboxID: inbox.ID}, + }, + Page: Page{DestMessageID: inboxFlags.ID + 999, Count: 10}, + } + testConn(api.Token(ctx), "", badDestMsgReq, func(start EventStart, evr eventReader) { + var vm EventViewMsgs + evr.Get("viewMsgs", &vm) + tcompare(t, len(vm.MessageItems), 2) + }) + + // Connection with missing unknown AnchorMessageID, resets view. + badAnchorMsgReq := Request{ + Query: Query{ + Filter: Filter{MailboxID: inbox.ID}, + }, + Page: Page{AnchorMessageID: inboxFlags.ID + 999, Count: 10}, + } + testConn(api.Token(ctx), "", badAnchorMsgReq, func(start EventStart, evr eventReader) { + var viewReset EventViewReset + evr.Get("viewReset", &viewReset) + + var vm EventViewMsgs + evr.Get("viewMsgs", &vm) + tcompare(t, len(vm.MessageItems), 2) + }) + + // Connection that starts with a filter, without mailbox. + searchReq := Request{ + Query: Query{ + Filter: Filter{Labels: []string{`\seen`}}, + }, + Page: Page{Count: 10}, + } + testConn(api.Token(ctx), "", searchReq, func(start EventStart, evr eventReader) { + var vm EventViewMsgs + evr.Get("viewMsgs", &vm) + tcompare(t, len(vm.MessageItems), 1) + tcompare(t, vm.MessageItems[0].Message.ID, inboxFlags.ID) + }) + + // Paginate from previous last element. There is nothing new. + var viewID int64 = 1 + api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: inbox.ID}}, Page: Page{Count: 10, AnchorMessageID: viewMsgs.MessageItems[len(viewMsgs.MessageItems)-1].Message.ID}}) + evr.Get("viewMsgs", &viewMsgs) + tcompare(t, len(viewMsgs.MessageItems), 0) + + // Request archive mailbox, empty. + viewID++ + api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: archive.ID}}, Page: Page{Count: 10}}) + evr.Get("viewMsgs", &viewMsgs) + tcompare(t, len(viewMsgs.MessageItems), 0) + tcompare(t, viewMsgs.ViewEnd, true) + + testFilter := func(orderAsc bool, f Filter, nf NotFilter, expIDs []int64) { + t.Helper() + viewID++ + api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{OrderAsc: orderAsc, Filter: f, NotFilter: nf}, Page: Page{Count: 10}}) + evr.Get("viewMsgs", &viewMsgs) + ids := make([]int64, len(viewMsgs.MessageItems)) + for i, mi := range viewMsgs.MessageItems { + ids[i] = mi.Message.ID + } + tcompare(t, ids, expIDs) + tcompare(t, viewMsgs.ViewEnd, true) + } + + // Test filtering. + var znf NotFilter + testFilter(false, Filter{MailboxID: lists.ID, MailboxChildrenIncluded: true}, znf, []int64{listsGoNutsMinimal.ID, listsMinimal.ID}) // Mailbox and sub mailbox. + testFilter(true, Filter{MailboxID: lists.ID, MailboxChildrenIncluded: true}, znf, []int64{listsMinimal.ID, listsGoNutsMinimal.ID}) // Oldest first first. + testFilter(false, Filter{MailboxID: -1}, znf, []int64{listsGoNutsMinimal.ID, listsMinimal.ID, inboxFlags.ID, inboxMinimal.ID}) // All except trash/junk/rejects. + testFilter(false, Filter{Labels: []string{`\seen`}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Labels: []string{`\seen`}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{Labels: []string{`testlabel`}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Labels: []string{`testlabel`}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{MailboxID: inbox.ID, Oldest: &inboxFlags.m.Received}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID, Newest: &inboxMinimal.m.Received}, znf, []int64{inboxMinimal.ID}) + testFilter(false, Filter{MailboxID: inbox.ID, SizeMin: inboxFlags.m.Size}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID, SizeMax: inboxMinimal.m.Size}, znf, []int64{inboxMinimal.ID}) + testFilter(false, Filter{From: []string{"mjl+altrel@mox.example"}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{From: []string{"mjl+altrel@mox.example"}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{To: []string{"mox+altrel@other.example"}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{To: []string{"mox+altrel@other.example"}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{From: []string{"mjl+altrel@mox.example", "bogus"}}, znf, []int64{}) + testFilter(false, Filter{To: []string{"mox+altrel@other.example", "bogus"}}, znf, []int64{}) + testFilter(false, Filter{Subject: []string{"test", "alt", "rel"}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Subject: []string{"alt"}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{MailboxID: inbox.ID, Words: []string{"the text body", "body", "the "}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Words: []string{"the text body"}}, []int64{inboxMinimal.ID}) + testFilter(false, Filter{Headers: [][2]string{{"X-Special", ""}}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{Headers: [][2]string{{"X-Special", "testing"}}}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{Headers: [][2]string{{"X-Special", "other"}}}, znf, []int64{}) + testFilter(false, Filter{Attachments: AttachmentImage}, znf, []int64{inboxFlags.ID}) + testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Attachments: AttachmentImage}, []int64{inboxMinimal.ID}) + + // Test changes. + getChanges := func(changes ...any) { + t.Helper() + var viewChanges EventViewChanges + evr.Get("viewChanges", &viewChanges) + if len(viewChanges.Changes) != len(changes) { + t.Fatalf("got %d changes, expected %d", len(viewChanges.Changes), len(changes)) + } + for i, dst := range changes { + src := viewChanges.Changes[i] + dstType := reflect.TypeOf(dst).Elem().Name() + if src[0] != dstType { + t.Fatalf("change %d is of type %s, expected %s", i, src[0], dstType) + } + // Marshal and unmarshal is easiest... + buf, err := json.Marshal(src[1]) + tcheck(t, err, "marshal change") + dec := json.NewDecoder(bytes.NewReader(buf)) + dec.DisallowUnknownFields() + err = dec.Decode(dst) + tcheck(t, err, "parsing change") + } + } + + // ChangeMailboxAdd + api.MailboxCreate(ctx, "Newbox") + var chmbadd ChangeMailboxAdd + getChanges(&chmbadd) + tcompare(t, chmbadd.Mailbox.Name, "Newbox") + + // ChangeMailboxRename + api.MailboxRename(ctx, chmbadd.Mailbox.ID, "Newbox2") + var chmbrename ChangeMailboxRename + getChanges(&chmbrename) + tcompare(t, chmbrename, ChangeMailboxRename{ + ChangeRenameMailbox: store.ChangeRenameMailbox{MailboxID: chmbadd.Mailbox.ID, OldName: "Newbox", NewName: "Newbox2", Flags: nil}, + }) + + // ChangeMailboxSpecialUse + api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: chmbadd.Mailbox.ID, SpecialUse: store.SpecialUse{Archive: true}}) + var chmbspecialuseOld, chmbspecialuseNew ChangeMailboxSpecialUse + getChanges(&chmbspecialuseOld, &chmbspecialuseNew) + tcompare(t, chmbspecialuseOld, ChangeMailboxSpecialUse{ + ChangeMailboxSpecialUse: store.ChangeMailboxSpecialUse{MailboxID: archive.ID, MailboxName: "Archive", SpecialUse: store.SpecialUse{}}, + }) + tcompare(t, chmbspecialuseNew, ChangeMailboxSpecialUse{ + ChangeMailboxSpecialUse: store.ChangeMailboxSpecialUse{MailboxID: chmbadd.Mailbox.ID, MailboxName: "Newbox2", SpecialUse: store.SpecialUse{Archive: true}}, + }) + + // ChangeMailboxRemove + api.MailboxDelete(ctx, chmbadd.Mailbox.ID) + var chmbremove ChangeMailboxRemove + getChanges(&chmbremove) + tcompare(t, chmbremove, ChangeMailboxRemove{ + ChangeRemoveMailbox: store.ChangeRemoveMailbox{MailboxID: chmbadd.Mailbox.ID, Name: "Newbox2"}, + }) + + // ChangeMsgAdd + inboxNew := &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0} + tdeliver(t, acc, inboxNew) + var chmsgadd ChangeMsgAdd + var chmbcounts ChangeMailboxCounts + getChanges(&chmsgadd, &chmbcounts) + tcompare(t, chmsgadd.ChangeAddUID.MailboxID, inbox.ID) + tcompare(t, chmsgadd.MessageItem.Message.ID, inboxNew.ID) + chmbcounts.Size = 0 + tcompare(t, chmbcounts, ChangeMailboxCounts{ + ChangeMailboxCounts: store.ChangeMailboxCounts{ + MailboxID: inbox.ID, + MailboxName: inbox.Name, + MailboxCounts: store.MailboxCounts{Total: 3, Unread: 2, Unseen: 2}, + }, + }) + + // ChangeMsgFlags + api.FlagsAdd(ctx, []int64{inboxNew.ID}, []string{`\seen`, `changelabel`, `aaa`}) + var chmsgflags ChangeMsgFlags + var chmbkeywords ChangeMailboxKeywords + getChanges(&chmsgflags, &chmbcounts, &chmbkeywords) + tcompare(t, chmsgadd.ChangeAddUID.MailboxID, inbox.ID) + tcompare(t, chmbkeywords, ChangeMailboxKeywords{ + ChangeMailboxKeywords: store.ChangeMailboxKeywords{ + MailboxID: inbox.ID, + MailboxName: inbox.Name, + Keywords: []string{`aaa`, `changelabel`}, + }, + }) + chmbcounts.Size = 0 + tcompare(t, chmbcounts, ChangeMailboxCounts{ + ChangeMailboxCounts: store.ChangeMailboxCounts{ + MailboxID: inbox.ID, + MailboxName: inbox.Name, + MailboxCounts: store.MailboxCounts{Total: 3, Unread: 1, Unseen: 1}, + }, + }) + + // ChangeMsgRemove + api.MessageDelete(ctx, []int64{inboxNew.ID, inboxMinimal.ID}) + var chmsgremove ChangeMsgRemove + getChanges(&chmbcounts, &chmsgremove) + tcompare(t, chmsgremove.ChangeRemoveUIDs.MailboxID, inbox.ID) + tcompare(t, chmsgremove.ChangeRemoveUIDs.UIDs, []store.UID{inboxMinimal.m.UID, inboxNew.m.UID}) + chmbcounts.Size = 0 + tcompare(t, chmbcounts, ChangeMailboxCounts{ + ChangeMailboxCounts: store.ChangeMailboxCounts{ + MailboxID: inbox.ID, + MailboxName: inbox.Name, + MailboxCounts: store.MailboxCounts{Total: 1}, + }, + }) + + // todo: check move operations and their changes, e.g. MailboxDelete, MailboxEmpty, MessageRemove. +} + +type eventReader struct { + t *testing.T + br *bufio.Reader + r io.Closer +} + +func (r eventReader) Get(name string, event any) { + timer := time.AfterFunc(2*time.Second, func() { + r.r.Close() + xlog.Print("event timeout") + }) + defer timer.Stop() + + t := r.t + t.Helper() + var ev string + var data []byte + var keepalive bool + for { + line, err := r.br.ReadBytes(byte('\n')) + tcheck(t, err, "read line") + line = bytes.TrimRight(line, "\n") + // fmt.Printf("have line %s\n", line) + + if bytes.HasPrefix(line, []byte("event: ")) { + ev = string(line[len("event: "):]) + } else if bytes.HasPrefix(line, []byte("data: ")) { + data = line[len("data: "):] + } else if bytes.HasPrefix(line, []byte(":")) { + keepalive = true + } else if len(line) == 0 { + if keepalive { + keepalive = false + continue + } + if ev != name { + t.Fatalf("got event %q (%s), expected %q", ev, data, name) + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + err := dec.Decode(event) + tcheck(t, err, "unmarshal json") + return + } + } +} diff --git a/webmail/webmail.go b/webmail/webmail.go new file mode 100644 index 0000000..851c30a --- /dev/null +++ b/webmail/webmail.go @@ -0,0 +1,1065 @@ +// Package webmail implements a webmail client, serving html/js and providing an API for message actions and SSE endpoint for receiving real-time updates. +package webmail + +// todo: should we be serving the messages/parts on a separate (sub)domain for user-content? to limit damage if the csp rules aren't enough. + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" + + _ "embed" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + + "golang.org/x/net/html" + + "github.com/mjl-/bstore" + "github.com/mjl-/sherpa" + + "github.com/mjl-/mox/message" + "github.com/mjl-/mox/metrics" + "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" +) + +func init() { + mox.LimitersInit() +} + +var xlog = mlog.New("webmail") + +// We pass the request to the sherpa handler so the TLS info can be used for +// the Received header in submitted messages. Most API calls need just the +// account name. +type ctxKey string + +var requestInfoCtxKey ctxKey = "requestInfo" + +type requestInfo struct { + LoginAddress string + AccountName string + Request *http.Request // For Proto and TLS connection state during message submit. +} + +//go:embed webmail.html +var webmailHTML []byte + +//go:embed webmail.js +var webmailJS []byte + +//go:embed msg.html +var webmailmsgHTML []byte + +//go:embed msg.js +var webmailmsgJS []byte + +//go:embed text.html +var webmailtextHTML []byte + +//go:embed text.js +var webmailtextJS []byte + +var ( + // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission + metricSubmission = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "mox_webmail_submission_total", + Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror.", + }, + []string{ + "result", + }, + ) + metricServerErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "mox_webmail_errors_total", + Help: "Webmail server errors, known values: dkimsign, submit.", + }, + []string{ + "error", + }, + ) + metricSSEConnections = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "mox_webmail_sse_connections", + Help: "Number of active webmail SSE connections.", + }, + ) +) + +func xcheckf(ctx context.Context, err error, format string, args ...any) { + if err == nil { + return + } + msg := fmt.Sprintf(format, args...) + errmsg := fmt.Sprintf("%s: %s", msg, err) + xlog.WithContext(ctx).Errorx(msg, err) + panic(&sherpa.Error{Code: "server:error", Message: errmsg}) +} + +func xcheckuserf(ctx context.Context, err error, format string, args ...any) { + if err == nil { + return + } + msg := fmt.Sprintf(format, args...) + errmsg := fmt.Sprintf("%s: %s", msg, err) + xlog.WithContext(ctx).Errorx(msg, err) + panic(&sherpa.Error{Code: "user:error", Message: errmsg}) +} + +func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) { + err := acc.DB.Write(ctx, func(tx *bstore.Tx) error { + fn(tx) + return nil + }) + xcheckf(ctx, err, "transaction") +} + +func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) { + err := acc.DB.Read(ctx, func(tx *bstore.Tx) error { + fn(tx) + return nil + }) + 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: "webmail/webmail.html", + jsPath: "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 + + 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() + } + out = m.combinedGzip + } + mtime = m.mtime + }() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(gzipInjector{w, gz}, 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 +} + +// 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") + } + w.ResponseWriter.WriteHeader(statusCode) +} + +// Serve content, either from a file, or return the fallback data. Caller +// should already have set the content-type. We use this to return a file from +// the local file system (during development), or embedded in the binary (when +// deployed). +func serveContentFallback(log *mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte) { + f, err := os.Open(path) + if err == nil { + defer f.Close() + st, err := f.Stat() + if err == nil { + http.ServeContent(w, r, "", st.ModTime(), f) + } + } + http.ServeContent(w, r, "", fallbackMtime(log), bytes.NewReader(fallback)) +} + +// Escape mime content header parameter, such as content-type charset or +// content-disposition filename. +func escapeParam(s string) string { + // todo: follow ../rfc/2183? + + basic := len(s) > 0 + for _, c := range s { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' { + continue + } + basic = false + break + } + if basic { + return s + } + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +// Handler returns a handler for the webmail endpoints, customized for the max +// message size coming from the listener. +func Handler(maxMessageSize int64) func(w http.ResponseWriter, r *http.Request) { + sh, err := makeSherpaHandler(maxMessageSize) + return func(w http.ResponseWriter, r *http.Request) { + if err != nil { + http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError) + return + } + handle(sh, w, r) + } +} + +func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) + + // Server-sent event connection, for all initial data (list of mailboxes), list of + // messages, and all events afterwards. Authenticated through a token in the query + // string, which it got from a Token API call. + if r.URL.Path == "/events" { + serveEvents(ctx, log, w, r) + return + } + + // HTTP Basic authentication for all requests. + loginAddress, accName := webaccount.CheckAuth(ctx, log, "webmail", w, r) + if accName == "" { + // Error response already sent. + return + } + + if lw, ok := w.(interface{ AddField(f mlog.Pair) }); ok { + lw.AddField(mlog.Field("authaccount", accName)) + } + + defer func() { + x := recover() + if x == nil { + return + } + err, ok := x.(*sherpa.Error) + if !ok { + log.WithContext(ctx).Error("handle panic", mlog.Field("err", x)) + debug.PrintStack() + metrics.PanicInc("webmail-handle") + panic(x) + } + if strings.HasPrefix(err.Code, "user:") { + log.Debugx("webmail user error", err) + http.Error(w, "400 - bad request - "+err.Message, http.StatusBadRequest) + } else { + log.Errorx("webmail server error", err) + http.Error(w, "500 - internal server error - "+err.Message, http.StatusInternalServerError) + } + }() + + switch r.URL.Path { + case "/": + switch r.Method { + default: + http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed) + return + case "GET", "HEAD": + } + + webmail.serve(ctx, log, w, r) + return + + case "/msg.js", "/text.js": + switch r.Method { + default: + http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed) + return + case "GET", "HEAD": + } + + path := filepath.Join("webmail", r.URL.Path[1:]) + var fallback = webmailmsgJS + if r.URL.Path == "/text.js" { + fallback = webmailtextJS + } + + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + serveContentFallback(log, w, r, path, fallback) + return + } + + // API calls. + if strings.HasPrefix(r.URL.Path, "/api/") { + reqInfo := requestInfo{loginAddress, accName, r} + ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo) + apiHandler.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // We are now expecting the following URLs: + // .../msg//{attachments.zip,parsedmessage.js,raw} + // .../msg//{,msg}{text,html,htmlexternal} + // .../msg//{view,viewtext,download}/ + + if !strings.HasPrefix(r.URL.Path, "/msg/") { + http.NotFound(w, r) + return + } + + t := strings.Split(r.URL.Path[len("/msg/"):], "/") + if len(t) < 2 { + http.NotFound(w, r) + return + } + + id, err := strconv.ParseInt(t[0], 10, 64) + if err != nil || id == 0 { + http.NotFound(w, r) + return + } + + // Many of the requests need either a message or a parsed part. Make it easy to + // fetch/prepare and cleanup. We only do all the work when the request seems legit + // (valid HTTP route and method). + xprepare := func() (acc *store.Account, m store.Message, msgr *store.MsgReader, p message.Part, cleanup func(), ok bool) { + if r.Method != "GET" { + http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed) + return + } + + defer func() { + if ok { + return + } + if msgr != nil { + err := msgr.Close() + log.Check(err, "closing message reader") + msgr = nil + } + if acc != nil { + err := acc.Close() + log.Check(err, "closing account") + acc = nil + } + }() + + var err error + + acc, err = store.OpenAccount(accName) + xcheckf(ctx, err, "open account") + + m = store.Message{ID: id} + err = acc.DB.Get(ctx, &m) + if err == bstore.ErrAbsent || err == nil && m.Expunged { + http.NotFound(w, r) + return + } + xcheckf(ctx, err, "get message") + + msgr = acc.MessageReader(m) + + p, err = m.LoadPart(msgr) + xcheckf(ctx, err, "load parsed message") + + cleanup = func() { + err := msgr.Close() + log.Check(err, "closing message reader") + err = acc.Close() + log.Check(err, "closing account") + } + ok = true + return + } + + h := w.Header() + + // We set a Content-Security-Policy header that is as strict as possible, depending + // on the type of message/part/html/js. We have to be careful because we are + // returning data that is coming in from external places. E.g. HTML could contain + // javascripts that we don't want to execute, especially not on our domain. We load + // resources in an iframe. The CSP policy starts out with default-src 'none' to + // disallow loading anything, then start allowing what is safe, such as inlined + // datauri images and inline styles. Data can only be loaded when the request is + // coming from the same origin (so other sites cannot include resources + // (messages/parts)). + // + // We want to load resources in sandbox-mode, causing the page to be loaded as from + // a different origin. If sameOrigin is set, we have a looser CSP policy: + // allow-same-origin is set so resources are loaded as coming from this same + // origin. This is needed for the msg* endpoints that render a message, where we + // load the message body in a separate iframe again (with stricter CSP again), + // which we need to access for its inner height. If allowSelfScript is also set + // (for "msgtext"), the CSP leaves out the sandbox entirely. + // + // If allowExternal is set, we allow loading image, media (audio/video), styles and + // fronts from external URLs as well as inline URI's. By default we don't allow any + // loading of content, except inlined images (we do that ourselves for images + // embedded in the email), and we allow inline styles (which are safely constrained + // to an iframe). + // + // If allowSelfScript is set, inline scripts and scripts from our origin are + // allowed. Used to display a message including header. The header is rendered with + // javascript, the content is rendered in a separate iframe with a CSP that doesn't + // have allowSelfScript. + headers := func(sameOrigin, allowExternal, allowSelfScript bool) { + // allow-popups is needed to make opening links in new tabs work. + sb := "sandbox allow-popups allow-popups-to-escape-sandbox; " + if sameOrigin && allowSelfScript { + // Sandbox with both allow-same-origin and allow-script would not provide security, + // and would give warning in console about that. + sb = "" + } else if sameOrigin { + sb = "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; " + } + script := "" + if allowSelfScript { + script = "; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'" + } + var csp string + if allowExternal { + csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:" + script + } else { + csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'" + script + } + h.Set("Content-Security-Policy", csp) + h.Set("X-Frame-Options", "sameorigin") // Duplicate with CSP, but better too much than too little. + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + } + + switch { + case len(t) == 2 && t[1] == "attachments.zip": + acc, m, msgr, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + state := msgState{acc: acc, m: m, msgr: msgr, part: &p} + // note: state is cleared by cleanup + + mi, err := messageItem(log, m, &state) + xcheckf(ctx, err, "parsing message") + + headers(false, false, false) + h.Set("Content-Type", "application/zip") + h.Set("Cache-Control", "no-cache, max-age=0") + var subjectSlug string + if p.Envelope != nil { + s := p.Envelope.Subject + s = strings.ToLower(s) + s = regexp.MustCompile("[^a-z0-9_.-]").ReplaceAllString(s, "-") + s = regexp.MustCompile("--*").ReplaceAllString(s, "-") + s = strings.TrimLeft(s, "-") + s = strings.TrimRight(s, "-") + if s != "" { + s = "-" + s + } + subjectSlug = s + } + filename := fmt.Sprintf("email-%d-attachments-%s%s.zip", m.ID, m.Received.Format("20060102-150405"), subjectSlug) + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%s`, escapeParam(filename))) + + zw := zip.NewWriter(w) + names := map[string]bool{} + for _, a := range mi.Attachments { + ap := a.Part + name := ap.ContentTypeParams["name"] + if name == "" { + // We don't check errors, this is all best-effort. + h, _ := ap.Header() + disposition := h.Get("Content-Disposition") + _, params, _ := mime.ParseMediaType(disposition) + name = params["filename"] + } + if name != "" { + name = filepath.Base(name) + } + mt := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType) + if name == "" || names[name] { + ext := filepath.Ext(name) + if ext == "" { + // Handle just a few basic types. + extensions := map[string]string{ + "text/plain": ".txt", + "text/html": ".html", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "application/zip": ".zip", + } + ext = extensions[mt] + if ext == "" { + ext = ".bin" + } + } + var stem string + if name != "" && strings.HasSuffix(name, ext) { + stem = strings.TrimSuffix(name, ext) + } else { + stem = "attachment" + for _, index := range a.Path { + stem += fmt.Sprintf("-%d", index) + } + } + name = stem + ext + seq := 0 + for names[name] { + seq++ + name = stem + fmt.Sprintf("-%d", seq) + ext + } + } + names[name] = true + + fh := zip.FileHeader{ + Name: name, + Modified: m.Received, + } + nodeflate := map[string]bool{ + "application/x-bzip2": true, + "application/zip": true, + "application/x-zip-compressed": true, + "application/gzip": true, + "application/x-gzip": true, + "application/vnd.rar": true, + "application/x-rar-compressed": true, + "application/x-7z-compressed": true, + } + // Sniff content-type as well for compressed data. + buf := make([]byte, 512) + n, _ := io.ReadFull(ap.Reader(), buf) + var sniffmt string + if n > 0 { + sniffmt = strings.ToLower(http.DetectContentType(buf[:n])) + } + deflate := ap.MediaType != "VIDEO" && ap.MediaType != "AUDIO" && (ap.MediaType != "IMAGE" || ap.MediaSubType == "BMP") && !nodeflate[mt] && !nodeflate[sniffmt] + if deflate { + fh.Method = zip.Deflate + } + // We cannot return errors anymore: we have already sent an application/zip header. + if zf, err := zw.CreateHeader(&fh); err != nil { + log.Check(err, "adding to zip file") + return + } else if _, err := io.Copy(zf, ap.Reader()); err != nil { + log.Check(err, "writing to zip file") + return + } + } + err = zw.Close() + log.Check(err, "final write to zip file") + + // Raw display of a message, as text/plain. + case len(t) == 2 && t[1] == "raw": + _, _, msgr, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + + // We intentially use text/plain. We certainly don't want to return a format that + // browsers or users would think of executing. We do set the charset if available + // on the outer part. If present, we assume it may be relevant for other parts. If + // not, there is not much we could do better... + headers(false, false, false) + ct := "text/plain" + if charset := p.ContentTypeParams["charset"]; charset != "" { + ct += fmt.Sprintf("; charset=%s", escapeParam(charset)) + } + h.Set("Content-Type", ct) + h.Set("Cache-Control", "no-cache, max-age=0") + + _, err := io.Copy(w, &moxio.AtReader{R: msgr}) + log.Check(err, "writing raw") + + case len(t) == 2 && (t[1] == "msgtext" || t[1] == "msghtml" || t[1] == "msghtmlexternal"): + // msg.html has a javascript tag with message data, and javascript to render the + // message header like the regular webmail.html and to load the message body in a + // separate iframe with a separate request with stronger CSP. + acc, m, msgr, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + + state := msgState{acc: acc, m: m, msgr: msgr, part: &p} + // note: state is cleared by cleanup + + pm, err := parsedMessage(log, m, &state, true, true) + xcheckf(ctx, err, "getting parsed message") + if t[1] == "msgtext" && len(pm.Texts) == 0 || t[1] != "msgtext" && !pm.HasHTML { + http.Error(w, "400 - bad request - no such part", http.StatusBadRequest) + return + } + + sameorigin := true + loadExternal := t[1] == "msghtmlexternal" + allowSelfScript := true + headers(sameorigin, loadExternal, allowSelfScript) + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-cache, max-age=0") + + path := "webmail/msg.html" + fallback := webmailmsgHTML + serveContentFallback(log, w, r, path, fallback) + + case len(t) == 2 && t[1] == "parsedmessage.js": + // Used by msg.html, for the msg* endpoints, for the data needed to show all data + // except the message body. + // This is js with data inside instead so we can load it synchronously, which we do + // to get a "loaded" event after the page was actually loaded. + + acc, m, msgr, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + state := msgState{acc: acc, m: m, msgr: msgr, part: &p} + // note: state is cleared by cleanup + + pm, err := parsedMessage(log, m, &state, true, true) + xcheckf(ctx, err, "parsing parsedmessage") + pmjson, err := json.Marshal(pm) + xcheckf(ctx, err, "marshal parsedmessage") + + m.MsgPrefix = nil + m.ParsedBuf = nil + mi := MessageItem{m, pm.envelope, pm.attachments, pm.isSigned, pm.isEncrypted, pm.firstLine} + mijson, err := json.Marshal(mi) + xcheckf(ctx, err, "marshal messageitem") + + headers(false, false, false) + h.Set("Content-Type", "application/javascript; charset=utf-8") + h.Set("Cache-Control", "no-cache, max-age=0") + + _, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson) + log.Check(err, "writing parsedmessage.js") + + case len(t) == 2 && t[1] == "text": + // Returns text.html whichs loads the message data with a javascript tag and + // renders just the text content with the same code as webmail.html. Used by the + // iframe in the msgtext endpoint. Not used by the regular webmail viewer, it + // renders the text itself, with the same shared js code. + acc, m, msgr, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + + state := msgState{acc: acc, m: m, msgr: msgr, part: &p} + // note: state is cleared by cleanup + + pm, err := parsedMessage(log, m, &state, true, true) + xcheckf(ctx, err, "parsing parsedmessage") + + if len(pm.Texts) == 0 { + http.Error(w, "400 - bad request - no text part in message", http.StatusBadRequest) + return + } + + // Needed for inner document height for outer iframe height in separate message view. + sameorigin := true + allowSelfScript := true + headers(sameorigin, false, allowSelfScript) + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-cache, max-age=0") + + // We typically return the embedded file, but during development it's handy to load + // from disk. + path := "webmail/text.html" + fallback := webmailtextHTML + serveContentFallback(log, w, r, path, fallback) + + case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"): + // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri + // if the referenced Content-ID attachment can be found. + _, _, _, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + + setHeaders := func() { + // Needed for inner document height for outer iframe height in separate message + // view. We only need that when displaying as a separate message on the msghtml* + // endpoints. When displaying in the regular webmail, we don't need to know the + // inner height so we load it as different origin, which should be safer. + sameorigin := r.URL.Query().Get("sameorigin") == "true" + allowExternal := strings.HasSuffix(t[1], "external") + headers(sameorigin, allowExternal, false) + + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-cache, max-age=0") + } + + // todo: skip certain html parts? e.g. with content-disposition: attachment? + var done bool + var usePart func(p *message.Part, parents []*message.Part) + usePart = func(p *message.Part, parents []*message.Part) { + if done { + return + } + mt := p.MediaType + "/" + p.MediaSubType + switch mt { + case "TEXT/HTML": + done = true + err := inlineSanitizeHTML(log, setHeaders, w, p, parents) + if err != nil { + http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest) + } + return + } + parents = append(parents, p) + for _, sp := range p.Parts { + usePart(&sp, parents) + } + } + usePart(&p, nil) + + if !done { + http.Error(w, "400 - bad request - no html part in message", http.StatusBadRequest) + } + + case len(t) == 3 && (t[1] == "view" || t[1] == "viewtext" || t[1] == "download"): + // View any part, as referenced in the last element path. "0" is the whole message, + // 0.0 is the first subpart, etc. "view" returns it with the content-type from the + // message (could be dangerous, but we set strict CSP headers), "viewtext" returns + // data with a text/plain content-type so the browser will attempt to display it, + // and "download" adds a content-disposition header causing the browser the + // download the file. + _, _, _, p, cleanup, ok := xprepare() + if !ok { + return + } + defer cleanup() + + paths := strings.Split(t[2], ".") + if len(paths) == 0 || paths[0] != "0" { + http.NotFound(w, r) + return + } + ap := p + for _, e := range paths[1:] { + index, err := strconv.ParseInt(e, 10, 32) + if err != nil || index < 0 || int(index) >= len(ap.Parts) { + http.NotFound(w, r) + return + } + ap = ap.Parts[int(index)] + } + + headers(false, false, false) + var ct string + if t[1] == "viewtext" { + ct = "text/plain" + } else { + ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType) + } + h.Set("Content-Type", ct) + h.Set("Cache-Control", "no-cache, max-age=0") + if t[1] == "download" { + name := ap.ContentTypeParams["name"] + if name == "" { + // We don't check errors, this is all best-effort. + h, _ := ap.Header() + disposition := h.Get("Content-Disposition") + _, params, _ := mime.ParseMediaType(disposition) + name = params["filename"] + } + if name == "" { + name = "attachment.bin" + } + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%s`, escapeParam(name))) + } + + _, err := io.Copy(w, ap.Reader()) + if err != nil && !moxio.IsClosed(err) { + log.Errorx("copying attachment", err) + } + default: + http.NotFound(w, r) + } +} + +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 +// scripts. If the HTML becomes too large, an error is returned. Before writing +// HTML, setHeaders is called to write the required headers for content-type and +// CSP. On error, setHeader is not called, no output is written and the caller +// should write an error response. +func inlineSanitizeHTML(log *mlog.Log, setHeaders func(), w io.Writer, p *message.Part, parents []*message.Part) error { + // Prepare cids if there is a chance we will use them. + cids := map[string]*message.Part{} + for _, parent := range parents { + if parent.MediaType+"/"+parent.MediaSubType == "MULTIPART/RELATED" && p.DecodedSize < 2*1024*1024 { + for i, rp := range parent.Parts { + if rp.ContentID != "" { + cids[strings.ToLower(rp.ContentID)] = &parent.Parts[i] + } + } + } + } + + node, err := html.Parse(p.ReaderUTF8OrBinary()) + if err != nil { + return fmt.Errorf("parsing html: %v", err) + } + + // We track size, if it becomes too much, we abort and still copy as regular html. + var totalSize int64 + if err := inlineNode(node, cids, &totalSize); err != nil { + return fmt.Errorf("inline cid uris in html nodes: %w", err) + } + sanitizeNode(node) + setHeaders() + err = html.Render(w, node) + log.Check(err, "writing html") + return nil +} + +// We inline cid: URIs into data: URIs. If a cid is missing in the +// multipart/related, we ignore the error and continue with other HTML nodes. It +// will probably just result in a "broken image". We limit the max size we +// generate. We only replace "src" attributes that start with "cid:". A cid URI +// could theoretically occur in many more places, like link href, and css url(). +// That's probably not common though. Let's wait for someone to need it. +func inlineNode(node *html.Node, cids map[string]*message.Part, totalSize *int64) error { + for i, a := range node.Attr { + if a.Key != "src" || !caselessPrefix(a.Val, "cid:") || a.Namespace != "" { + continue + } + cid := a.Val[4:] + ap := cids["<"+strings.ToLower(cid)+">"] + if ap == nil { + // Missing cid, can happen with email, no need to stop returning data. + continue + } + *totalSize += ap.DecodedSize + if *totalSize >= 10*1024*1024 { + return fmt.Errorf("html too large") + } + var sb strings.Builder + if _, err := fmt.Fprintf(&sb, "data:%s;base64,", strings.ToLower(ap.MediaType+"/"+ap.MediaSubType)); err != nil { + return fmt.Errorf("writing datauri: %v", err) + } + w := base64.NewEncoder(base64.StdEncoding, &sb) + if _, err := io.Copy(w, ap.Reader()); err != nil { + return fmt.Errorf("writing base64 datauri: %v", err) + } + node.Attr[i].Val = sb.String() + } + for node = node.FirstChild; node != nil; node = node.NextSibling { + if err := inlineNode(node, cids, totalSize); err != nil { + return err + } + } + return nil +} + +func caselessPrefix(k, pre string) bool { + return len(k) >= len(pre) && strings.EqualFold(k[:len(pre)], pre) +} + +var targetable = map[string]bool{ + "a": true, + "area": true, + "form": true, + "base": true, +} + +// sanitizeNode removes script elements, on* attributes, javascript: href +// attributes, adds target="_blank" to all links and to a base tag. +func sanitizeNode(node *html.Node) { + i := 0 + var haveTarget, haveRel bool + for i < len(node.Attr) { + a := node.Attr[i] + // Remove dangerous attributes. + if strings.HasPrefix(a.Key, "on") || a.Key == "href" && caselessPrefix(a.Val, "javascript:") || a.Key == "src" && caselessPrefix(a.Val, "data:text/html") { + copy(node.Attr[i:], node.Attr[i+1:]) + node.Attr = node.Attr[:len(node.Attr)-1] + continue + } + if a.Key == "target" { + node.Attr[i].Val = "_blank" + haveTarget = true + } + if a.Key == "rel" && targetable[node.Data] { + node.Attr[i].Val = "noopener noreferrer" + haveRel = true + } + i++ + } + // Ensure target attribute is set for elements that can have it. + if !haveTarget && node.Type == html.ElementNode && targetable[node.Data] { + node.Attr = append(node.Attr, html.Attribute{Key: "target", Val: "_blank"}) + haveTarget = true + } + if haveTarget && !haveRel { + node.Attr = append(node.Attr, html.Attribute{Key: "rel", Val: "noopener noreferrer"}) + } + + parent := node + node = node.FirstChild + var haveBase bool + for node != nil { + // Set next now, we may remove cur, which clears its NextSibling. + cur := node + node = node.NextSibling + + // Remove script elements. + if cur.Type == html.ElementNode && cur.Data == "script" { + parent.RemoveChild(cur) + continue + } + sanitizeNode(cur) + } + if parent.Type == html.ElementNode && parent.Data == "head" && !haveBase { + n := html.Node{Type: html.ElementNode, Data: "base", Attr: []html.Attribute{{Key: "target", Val: "_blank"}, {Key: "rel", Val: "noopener noreferrer"}}} + parent.AppendChild(&n) + } +} diff --git a/webmail/webmail.html b/webmail/webmail.html new file mode 100644 index 0000000..aa21f18 --- /dev/null +++ b/webmail/webmail.html @@ -0,0 +1,75 @@ + + + + Mox Webmail + + + + + + +
Loading...
+ + + diff --git a/webmail/webmail.js b/webmail/webmail.js new file mode 100644 index 0000000..c291060 --- /dev/null +++ b/webmail/webmail.js @@ -0,0 +1,4776 @@ +"use strict"; +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY +var api; +(function (api) { + // Validation of "message From" domain. + let Validation; + (function (Validation) { + Validation[Validation["ValidationUnknown"] = 0] = "ValidationUnknown"; + Validation[Validation["ValidationStrict"] = 1] = "ValidationStrict"; + Validation[Validation["ValidationDMARC"] = 2] = "ValidationDMARC"; + Validation[Validation["ValidationRelaxed"] = 3] = "ValidationRelaxed"; + Validation[Validation["ValidationPass"] = 4] = "ValidationPass"; + Validation[Validation["ValidationNeutral"] = 5] = "ValidationNeutral"; + Validation[Validation["ValidationTemperror"] = 6] = "ValidationTemperror"; + Validation[Validation["ValidationPermerror"] = 7] = "ValidationPermerror"; + Validation[Validation["ValidationFail"] = 8] = "ValidationFail"; + Validation[Validation["ValidationSoftfail"] = 9] = "ValidationSoftfail"; + Validation[Validation["ValidationNone"] = 10] = "ValidationNone"; + })(Validation = api.Validation || (api.Validation = {})); + // AttachmentType is for filtering by attachment type. + let AttachmentType; + (function (AttachmentType) { + AttachmentType["AttachmentIndifferent"] = ""; + AttachmentType["AttachmentNone"] = "none"; + AttachmentType["AttachmentAny"] = "any"; + AttachmentType["AttachmentImage"] = "image"; + AttachmentType["AttachmentPDF"] = "pdf"; + AttachmentType["AttachmentArchive"] = "archive"; + AttachmentType["AttachmentSpreadsheet"] = "spreadsheet"; + AttachmentType["AttachmentDocument"] = "document"; + AttachmentType["AttachmentPresentation"] = "presentation"; + })(AttachmentType = api.AttachmentType || (api.AttachmentType = {})); + api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "Request": true, "SpecialUse": true, "SubmitMessage": true }; + api.stringsTypes = { "AttachmentType": true, "Localpart": true }; + api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; + api.types = { + "Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] }, + "Query": { "Name": "Query", "Docs": "", "Fields": [{ "Name": "OrderAsc", "Docs": "", "Typewords": ["bool"] }, { "Name": "Filter", "Docs": "", "Typewords": ["Filter"] }, { "Name": "NotFilter", "Docs": "", "Typewords": ["NotFilter"] }] }, + "Filter": { "Name": "Filter", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxChildrenIncluded", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Oldest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Newest", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "SizeMin", "Docs": "", "Typewords": ["int64"] }, { "Name": "SizeMax", "Docs": "", "Typewords": ["int64"] }] }, + "NotFilter": { "Name": "NotFilter", "Docs": "", "Fields": [{ "Name": "Words", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["AttachmentType"] }, { "Name": "Labels", "Docs": "", "Typewords": ["[]", "string"] }] }, + "Page": { "Name": "Page", "Docs": "", "Fields": [{ "Name": "AnchorMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Count", "Docs": "", "Typewords": ["int32"] }, { "Name": "DestMessageID", "Docs": "", "Typewords": ["int64"] }] }, + "ParsedMessage": { "Name": "ParsedMessage", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }, { "Name": "Headers", "Docs": "", "Typewords": ["{}", "[]", "string"] }, { "Name": "Texts", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HasHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListReplyAddress", "Docs": "", "Typewords": ["nullable", "MessageAddress"] }] }, + "Part": { "Name": "Part", "Docs": "", "Fields": [{ "Name": "BoundaryOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "HeaderOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "BodyOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "EndOffset", "Docs": "", "Typewords": ["int64"] }, { "Name": "RawLineCount", "Docs": "", "Typewords": ["int64"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "MediaType", "Docs": "", "Typewords": ["string"] }, { "Name": "MediaSubType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentDescription", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTransferEncoding", "Docs": "", "Typewords": ["string"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["nullable", "Envelope"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Part"] }, { "Name": "Message", "Docs": "", "Typewords": ["nullable", "Part"] }] }, + "Envelope": { "Name": "Envelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "Address"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, + "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, + "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, + "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, + "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, + "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, + "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, + "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, + "MessageItem": { "Name": "MessageItem", "Docs": "", "Fields": [{ "Name": "Message", "Docs": "", "Typewords": ["Message"] }, { "Name": "Envelope", "Docs": "", "Typewords": ["MessageEnvelope"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "Attachment"] }, { "Name": "IsSigned", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsEncrypted", "Docs": "", "Typewords": ["bool"] }, { "Name": "FirstLine", "Docs": "", "Typewords": ["string"] }] }, + "Message": { "Name": "Message", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "CreateSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Expunged", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailboxOrigID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxDestinedID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked1", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked2", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPMasked3", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLODomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MailFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "RcptToLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RcptToDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "MsgFromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromOrgDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "EHLOValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "EHLOValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MailFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "MsgFromValidation", "Docs": "", "Typewords": ["Validation"] }, { "Name": "DKIMDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageHash", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "TrainedJunk", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "ParsedBuf", "Docs": "", "Typewords": ["nullable", "string"] }] }, + "MessageEnvelope": { "Name": "MessageEnvelope", "Docs": "", "Fields": [{ "Name": "Date", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "Sender", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }] }, + "Attachment": { "Name": "Attachment", "Docs": "", "Fields": [{ "Name": "Path", "Docs": "", "Typewords": ["[]", "int32"] }, { "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "Part", "Docs": "", "Typewords": ["Part"] }] }, + "EventViewChanges": { "Name": "EventViewChanges", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Changes", "Docs": "", "Typewords": ["[]", "[]", "any"] }] }, + "ChangeMsgAdd": { "Name": "ChangeMsgAdd", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MessageItem", "Docs": "", "Typewords": ["MessageItem"] }] }, + "Flags": { "Name": "Flags", "Docs": "", "Fields": [{ "Name": "Seen", "Docs": "", "Typewords": ["bool"] }, { "Name": "Answered", "Docs": "", "Typewords": ["bool"] }, { "Name": "Flagged", "Docs": "", "Typewords": ["bool"] }, { "Name": "Forwarded", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Notjunk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Phishing", "Docs": "", "Typewords": ["bool"] }, { "Name": "MDNSent", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMsgRemove": { "Name": "ChangeMsgRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UIDs", "Docs": "", "Typewords": ["[]", "UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }] }, + "ChangeMsgFlags": { "Name": "ChangeMsgFlags", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "UID", "Docs": "", "Typewords": ["UID"] }, { "Name": "ModSeq", "Docs": "", "Typewords": ["ModSeq"] }, { "Name": "Mask", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Flags", "Docs": "", "Typewords": ["Flags"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxRemove": { "Name": "ChangeMailboxRemove", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }] }, + "ChangeMailboxAdd": { "Name": "ChangeMailboxAdd", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["Mailbox"] }] }, + "ChangeMailboxRename": { "Name": "ChangeMailboxRename", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "OldName", "Docs": "", "Typewords": ["string"] }, { "Name": "NewName", "Docs": "", "Typewords": ["string"] }, { "Name": "Flags", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ChangeMailboxCounts": { "Name": "ChangeMailboxCounts", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, + "ChangeMailboxSpecialUse": { "Name": "ChangeMailboxSpecialUse", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "SpecialUse", "Docs": "", "Typewords": ["SpecialUse"] }] }, + "SpecialUse": { "Name": "SpecialUse", "Docs": "", "Fields": [{ "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }] }, + "ChangeMailboxKeywords": { "Name": "ChangeMailboxKeywords", "Docs": "", "Fields": [{ "Name": "MailboxID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }] }, + "UID": { "Name": "UID", "Docs": "", "Values": null }, + "ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null }, + "Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] }, + "AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] }, + "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, + }; + api.parser = { + Request: (v) => api.parse("Request", v), + Query: (v) => api.parse("Query", v), + Filter: (v) => api.parse("Filter", v), + NotFilter: (v) => api.parse("NotFilter", v), + Page: (v) => api.parse("Page", v), + ParsedMessage: (v) => api.parse("ParsedMessage", v), + Part: (v) => api.parse("Part", v), + Envelope: (v) => api.parse("Envelope", v), + Address: (v) => api.parse("Address", v), + MessageAddress: (v) => api.parse("MessageAddress", v), + Domain: (v) => api.parse("Domain", v), + SubmitMessage: (v) => api.parse("SubmitMessage", v), + File: (v) => api.parse("File", v), + ForwardAttachments: (v) => api.parse("ForwardAttachments", v), + Mailbox: (v) => api.parse("Mailbox", v), + EventStart: (v) => api.parse("EventStart", v), + DomainAddressConfig: (v) => api.parse("DomainAddressConfig", v), + EventViewErr: (v) => api.parse("EventViewErr", v), + EventViewReset: (v) => api.parse("EventViewReset", v), + EventViewMsgs: (v) => api.parse("EventViewMsgs", v), + MessageItem: (v) => api.parse("MessageItem", v), + Message: (v) => api.parse("Message", v), + MessageEnvelope: (v) => api.parse("MessageEnvelope", v), + Attachment: (v) => api.parse("Attachment", v), + EventViewChanges: (v) => api.parse("EventViewChanges", v), + ChangeMsgAdd: (v) => api.parse("ChangeMsgAdd", v), + Flags: (v) => api.parse("Flags", v), + ChangeMsgRemove: (v) => api.parse("ChangeMsgRemove", v), + ChangeMsgFlags: (v) => api.parse("ChangeMsgFlags", v), + ChangeMailboxRemove: (v) => api.parse("ChangeMailboxRemove", v), + ChangeMailboxAdd: (v) => api.parse("ChangeMailboxAdd", v), + ChangeMailboxRename: (v) => api.parse("ChangeMailboxRename", v), + ChangeMailboxCounts: (v) => api.parse("ChangeMailboxCounts", v), + ChangeMailboxSpecialUse: (v) => api.parse("ChangeMailboxSpecialUse", v), + SpecialUse: (v) => api.parse("SpecialUse", v), + ChangeMailboxKeywords: (v) => api.parse("ChangeMailboxKeywords", v), + UID: (v) => api.parse("UID", v), + ModSeq: (v) => api.parse("ModSeq", v), + Validation: (v) => api.parse("Validation", v), + AttachmentType: (v) => api.parse("AttachmentType", v), + Localpart: (v) => api.parse("Localpart", v), + }; + let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true }; + class Client { + 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 }); + } + // Token returns a token to use for an SSE connection. A token can only be used for + // a single SSE connection. Tokens are stored in memory for a maximum of 1 minute, + // with at most 10 unused tokens (the most recently created) per account. + async Token() { + const fn = "Token"; + const paramTypes = []; + const returnTypes = [["string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Requests sends a new request for an open SSE connection. Any currently active + // request for the connection will be canceled, but this is done asynchrously, so + // the SSE connection may still send results for the previous request. Callers + // should take care to ignore such results. If req.Cancel is set, no new request is + // started. + async Request(req) { + const fn = "Request"; + const paramTypes = [["Request"]]; + const returnTypes = []; + const params = [req]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ParsedMessage returns enough to render the textual body of a message. It is + // assumed the client already has other fields through MessageItem. + async ParsedMessage(msgID) { + const fn = "ParsedMessage"; + const paramTypes = [["int64"]]; + const returnTypes = [["ParsedMessage"]]; + const params = [msgID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageSubmit sends a message by submitting it the outgoing email queue. The + // message is sent to all addresses listed in the To, Cc and Bcc addresses, without + // Bcc message header. + // + // If a Sent mailbox is configured, messages are added to it after submitting + // to the delivery queue. + async MessageSubmit(m) { + const fn = "MessageSubmit"; + const paramTypes = [["SubmitMessage"]]; + const returnTypes = []; + const params = [m]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageMove moves messages to another mailbox. If the message is already in + // the mailbox an error is returned. + async MessageMove(messageIDs, mailboxID) { + const fn = "MessageMove"; + const paramTypes = [["[]", "int64"], ["int64"]]; + const returnTypes = []; + const params = [messageIDs, mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MessageDelete permanently deletes messages, without moving them to the Trash mailbox. + async MessageDelete(messageIDs) { + const fn = "MessageDelete"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [messageIDs]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsAdd adds flags, either system flags like \Seen or custom keywords. The + // flags should be lower-case, but will be converted and verified. + async FlagsAdd(messageIDs, flaglist) { + const fn = "FlagsAdd"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // FlagsClear clears flags, either system flags like \Seen or custom keywords. + async FlagsClear(messageIDs, flaglist) { + const fn = "FlagsClear"; + const paramTypes = [["[]", "int64"], ["[]", "string"]]; + const returnTypes = []; + const params = [messageIDs, flaglist]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxCreate creates a new mailbox. + async MailboxCreate(name) { + const fn = "MailboxCreate"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [name]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxDelete deletes a mailbox and all its messages. + async MailboxDelete(mailboxID) { + const fn = "MailboxDelete"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not + // its child mailboxes. + async MailboxEmpty(mailboxID) { + const fn = "MailboxEmpty"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [mailboxID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox + // ID and its messages are unchanged. + async MailboxRename(mailboxID, newName) { + const fn = "MailboxRename"; + const paramTypes = [["int64"], ["string"]]; + const returnTypes = []; + const params = [mailboxID, newName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // CompleteRecipient returns autocomplete matches for a recipient, returning the + // matches, most recently used first, and whether this is the full list and further + // requests for longer prefixes aren't necessary. + async CompleteRecipient(search) { + const fn = "CompleteRecipient"; + const paramTypes = [["string"]]; + const returnTypes = [["[]", "string"], ["bool"]]; + const params = [search]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MailboxSetSpecialUse sets the special use flags of a mailbox. + async MailboxSetSpecialUse(mb) { + const fn = "MailboxSetSpecialUse"; + const paramTypes = [["Mailbox"]]; + const returnTypes = []; + const params = [mb]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // SSETypes exists to ensure the generated API contains the types, for use in SSE events. + async SSETypes() { + const fn = "SSETypes"; + const paramTypes = []; + const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]]; + 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 { + 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 [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 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]; +})(); +// join elements in l with the results of calls to efn. efn can return +// HTMLElements, which cannot be inserted into the dom multiple times, hence the +// function. +const join = (l, efn) => { + const r = []; + const n = l.length; + for (let i = 0; i < n; i++) { + r.push(l[i]); + if (i < n - 1) { + r.push(efn()); + } + } + return r; +}; +// addLinks turns a line of text into alternating strings and links. Links that +// would end with interpunction followed by whitespace are returned with that +// interpunction moved to the next string instead. +const addLinks = (text) => { + // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. + const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const r = []; + while (text.length > 0) { + const l = re.exec(text); + if (!l) { + r.push(text); + break; + } + let s = text.substring(0, l.index); + let url = l[0]; + text = text.substring(l.index + url.length); + r.push(s); + // If URL ends with interpunction, and next character is whitespace or end, don't + // include the interpunction in the URL. + if (/[!),.:;>?]$/.test(url) && (!text || /^[ \t\r\n]/.test(text))) { + text = url.substring(url.length - 1) + text; + url = url.substring(0, url.length - 1); + } + r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + } + return r; +}; +// renderText turns text into a renderable element with ">" interpreted as quoted +// text (with different levels), and URLs replaced by links. +const renderText = (text) => { + return dom.div(text.split('\n').map(line => { + let q = 0; + for (const c of line) { + if (c == '>') { + q++; + } + else if (c !== ' ') { + break; + } + } + if (q == 0) { + return [addLinks(line), '\n']; + } + q = (q - 1) % 3 + 1; + return dom.div(dom._class('quoted' + q), addLinks(line)); + })); +}; +const displayName = (s) => { + // ../rfc/5322:1216 + // ../rfc/5322:1270 + // todo: need support for group addresses (eg "undisclosed recipients"). + // ../rfc/5322:697 + const specials = /[()<>\[\]:;@\\,."]/; + if (specials.test(s)) { + return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'; + } + return s; +}; +// format an address with both name and email address. +const formatAddress = (a) => { + let s = '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Name) { + s = displayName(a.Name) + ' ' + s; + } + return s; +}; +// returns an address with all available details, including unicode version if +// available. +const formatAddressFull = (a) => { + let s = ''; + if (a.Name) { + s = a.Name + ' '; + } + s += '<' + a.User + '@' + a.Domain.ASCII + '>'; + if (a.Domain.Unicode) { + s += ' (' + a.User + '@' + a.Domain.Unicode + ')'; + } + return s; +}; +// format just the name, or otherwies just the email address. +const formatAddressShort = (a) => { + if (a.Name) { + return a.Name; + } + return '<' + a.User + '@' + a.Domain.ASCII + '>'; +}; +// return just the email address. +const formatEmailASCII = (a) => { + return a.User + '@' + a.Domain.ASCII; +}; +const equalAddress = (a, b) => { + return (!a.User || !b.User || a.User === b.User) && a.Domain.ASCII === b.Domain.ASCII; +}; +// loadMsgheaderView loads the common message headers into msgheaderelem. +// if refineKeyword is set, labels are shown and a click causes a call to +// refineKeyword. +const loadMsgheaderView = (msgheaderelem, mi, refineKeyword) => { + const msgenv = mi.Envelope; + const received = mi.Message.Received; + const receivedlocal = new Date(received.getTime() - received.getTimezoneOffset() * 60 * 1000); + dom._kids(msgheaderelem, + // todo: make addresses clickable, start search (keep current mailbox if any) + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.To || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.CC || []).map(a => formatAddressFull(a)), () => ', '))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.BCC || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + await refineKeyword(kw); + })) : []))))); +}; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +/* +Webmail is a self-contained webmail client. + +Typescript is used for type safety, but otherwise we try not to rely on any +JS/TS tools/frameworks etc, they often complicate/obscure how things work. The +DOM and styles are directly manipulated, so to develop on this code you need to +know about DOM functions. With a few helper functions in the dom object, +interaction with the DOM is still relatively high-level, but also allows for +more low-level techniques like rendering of text in a way that highlights text +that switches unicode blocks/scripts. We use typescript in strict mode, see +top-level tsc.sh. We often specify types for function parameters, but not +return types, since typescript is good at deriving return types. + +There is no mechanism to automatically update a view when properties change. The +UI is split/isolated in components called "views", which expose only their root +HTMLElement for inclusion in another component or the top-level document. A view +has functions that other views (e.g. parents) can call for to propagate updates +or retrieve data. We have these views: + +- Mailboxlist, in the bar on the list with all mailboxes. +- Mailbox, a single mailbox in the mailbox list. +- Search, with form for search criteria, opened through search bar. +- Msglist, the list of messages for the selected mailbox or search query. +- Msgitem, a message in Msglist, shown as a single line. +- Msg, showing the contents of a single selected message. +- Compose, when writing a new message (or reply/forward). + +Most of the data is transferred over an SSE connection. It sends the initial +list of mailboxes, sends message summaries for the currently selected mailbox or +search query and sends changes as they happen, e.g. added/removed messages, +changed flags, etc. Operations that modify data are done through API calls. The +typescript API is generated from the Go types and functions. Displayed message +contents are also retrieved through an API call. + +HTML messages are potentially dangerous. We display them in a separate iframe, +with contents served in a separate HTTP request, with Content-Security-Policy +headers that prevent executing scripts or loading potentially unwanted remote +resources. We cannot load the HTML in an inline iframe, because the iframe "csp" +attribute to set a Content-Security-Policy is not supported by all modern +browsers (safari and firefox don't support it at the time of writing). Text +messages are rendered inside the webmail client, making URLs clickable, +highlighting unicode script/block changes and rendering quoted text in a +different color. + +Browsers to test with: Firefox, Chromium, Safari, Edge. + +To simulate slow API calls and SSE events: +window.localStorage.setItem('sherpats-debug', JSON.stringify({waitMinMsec: 2000, waitMaxMsec: 4000})) + +Show additional headers of messages: +settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id']}) + +- todo: threading (needs support in mox first) +- todo: in msglistView, show names of people we have sent to, and address otherwise. +- todo: implement settings stored in the server, such as mailboxCollapsed, keyboard shortcuts. also new settings for displaying email as html by default for configured sender address or domain. name to use for "From", optional default Reply-To and Bcc addresses, signatures (per address), configured labels/keywords with human-readable name, colors and toggling with shortcut keys 1-9. +- todo: in msglist, if our address is in the from header, list addresses in the to/cc/bcc, it's likely a sent folder +- todo: automated tests? perhaps some unit tests, then ui scenario's. +- todo: compose, wrap lines +- todo: composing of html messages. possibly based on contenteditable. would be good if we can include original html, but quoted. must make sure to not include dangerous scripts/resources, or sandbox it. +- todo: make alt up/down keys work on html iframe too. requires loading it from sameorigin, to get access to its inner document. +- todo: reconnect with last known modseq and don't clear the message list, only update it +- todo: resize and move of compose window +- todo: find and use svg icons for flags in the msgitemView. junk (fire), forwarded, replied, attachment (paperclip), flagged (flag), phishing (?). also for special-use mailboxes (junk, trash, archive, draft, sent). should be basic and slim. +- todo: for embedded messages (message/rfc822 or message/global), allow viewing it as message, perhaps in a popup? +- todo: for content-disposition: inline, show images alongside text? +- todo: only show orange underline where it could be a problem? in addresses and anchor texts. we may be lighting up a christmas tree now, desensitizing users. +- todo: saved searches that are displayed below list of mailboxes, for quick access to preset view +- todo: when search on free-form text is active, highlight the searched text in the message view. +- todo: when reconnecting, request only the changes to the current state/msglist, passing modseq query string parameter +- todo: composeView: save as draft, periodically and when closing. +- todo: forwarding of html parts, including inline attachments, so the html version can be rendered like the original by the receiver. +- todo: buttons/mechanism to operate on all messages in a mailbox/search query, without having to list and select all messages. e.g. clearing flags/labels. +- todo: can we detect if browser supports proper CSP? if not, refuse to load html messages? +- todo: more search criteria? Date header field (instead of time received), text vs html (only, either or both), attachment filenames and sizes +- todo: integrate more of the account page into webmail? importing/exporting messages, configuring delivery rules (possibly with sieve). for messages moved out of inbox to non-special-use mailbox, show button that helps make an automatic rule to move such messages again (e.g. based on message From address, message From domain or List-ID header). +- todo: configurable keyboard shortcuts? we use strings like "ctrl p" which we already generate and match on, add a mapping from command name to cmd* functions, and have a map of keys to command names. the commands for up/down with shift/ctrl modifiers may need special attention. +- todo: nicer address input fields like other mail clients do. with tab to autocomplete and turn input into a box and delete removing of the entire address. +- todo: consider composing messages with bcc headers that are kept as message Bcc headers, optionally with checkbox. +- todo: improve accessibility +- todo: msglistView: preload next message? +- todo: previews of zip files +- todo: undo? +- todo: mute threads? +- todo: mobile-friendly version. should perhaps be a completely different app, because it is so different. +- todo: msglistView: for mailbox views (which are fast to list the results of), should we ask the full number of messages, set the height of the scroll div based on the number of messages, then request messages when user scrolls, putting the messages in place. not sure if worth the trouble. +- todo: basic vim key bindings in textarea/input. or just let users use a browser plugin. +*/ +const zindexes = { + splitter: '1', + compose: '2', + searchView: '3', + searchbar: '4', + popup: '5', + popover: '5', + attachments: '5', + shortcut: '6', +}; +// All logging goes through log() instead of console.log, except "should not happen" logging. +let log = () => { }; +try { + if (localStorage.getItem('log')) { + log = console.log; + } +} +catch (err) { } +const defaultSettings = { + showShortcuts: true, + mailboxesWidth: 240, + layout: 'auto', + leftWidthPct: 50, + topHeightPct: 40, + msglistflagsWidth: 40, + msglistageWidth: 70, + msglistfromPct: 30, + refine: '', + orderAsc: false, + ignoreErrorsUntil: 0, + showHTML: false, + mailboxCollapsed: {}, + showAllHeaders: false, + showHeaders: [], // Additional message headers to show. +}; +const parseSettings = () => { + try { + const v = window.localStorage.getItem('settings'); + if (!v) { + return { ...defaultSettings }; + } + const x = JSON.parse(v); + const def = defaultSettings; + const getString = (k, ...l) => { + const v = x[k]; + if (typeof v !== 'string' || l.length > 0 && !l.includes(v)) { + return def[k]; + } + return v; + }; + const getBool = (k) => { + const v = x[k]; + return typeof v === 'boolean' ? v : def[k]; + }; + const getInt = (k) => { + const v = x[k]; + return typeof v === 'number' ? v : def[k]; + }; + let mailboxCollapsed = x.mailboxCollapsed; + if (!mailboxCollapsed || typeof mailboxCollapsed !== 'object') { + mailboxCollapsed = def.mailboxCollapsed; + } + const getStringArray = (k) => { + const v = x[k]; + if (v && Array.isArray(v) && (v.length === 0 || typeof v[0] === 'string')) { + return v; + } + return def[k]; + }; + return { + refine: getString('refine'), + orderAsc: getBool('orderAsc'), + mailboxesWidth: getInt('mailboxesWidth'), + leftWidthPct: getInt('leftWidthPct'), + topHeightPct: getInt('topHeightPct'), + msglistflagsWidth: getInt('msglistflagsWidth'), + msglistageWidth: getInt('msglistageWidth'), + msglistfromPct: getInt('msglistfromPct'), + ignoreErrorsUntil: getInt('ignoreErrorsUntil'), + layout: getString('layout', 'auto', 'leftright', 'topbottom'), + showShortcuts: getBool('showShortcuts'), + showHTML: getBool('showHTML'), + mailboxCollapsed: mailboxCollapsed, + showAllHeaders: getBool('showAllHeaders'), + showHeaders: getStringArray('showHeaders'), + }; + } + catch (err) { + console.log('getting settings from localstorage', err); + return { ...defaultSettings }; + } +}; +// Store new settings. Called as settingsPut({...settings, updatedField: newValue}). +const settingsPut = (nsettings) => { + settings = nsettings; + try { + window.localStorage.setItem('settings', JSON.stringify(nsettings)); + } + catch (err) { + console.log('storing settings in localstorage', err); + } +}; +let settings = parseSettings(); +// All addresses for this account, can include "@domain" wildcard, User is empty in +// that case. Set when SSE connection is initialized. +let accountAddresses = []; +// Username/email address of login. Used as default From address when composing +// a new message. +let loginAddress = null; +// Localpart config (catchall separator and case sensitivity) for each domain +// the account has an address for. +let domainAddressConfigs = {}; +const client = new api.Client(); +// Link returns a clickable link with rel="noopener noreferrer". +const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href); +// Returns first own account address matching an address in l. +const envelopeIdentity = (l) => { + for (const a of l) { + const ma = accountAddresses.find(aa => (!aa.User || aa.User === a.User) && aa.Domain.ASCII === a.Domain.ASCII); + if (ma) { + return { Name: ma.Name, User: a.User, Domain: a.Domain }; + } + } + return null; +}; +// We can display keyboard shortcuts when a user clicks a button that has a shortcut. +let shortcutElem = dom.div(style({ fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em' })); +let shortcutTimer = 0; +const showShortcut = (c) => { + if (!settings.showShortcuts) { + return; + } + if (shortcutTimer) { + window.clearTimeout(shortcutTimer); + } + shortcutElem.remove(); + dom._kids(shortcutElem, c); + document.body.appendChild(shortcutElem); + shortcutTimer = setTimeout(() => { + shortcutElem.remove(); + shortcutTimer = 0; + }, 1500); +}; +// Call cmdfn and display the shortcut for the command if it occurs in shortcuts. +const shortcutCmd = async (cmdfn, shortcuts) => { + let shortcut = ''; + for (const k in shortcuts) { + if (shortcuts[k] == cmdfn) { + shortcut = k; + break; + } + } + if (shortcut) { + showShortcut(shortcut); + } + await cmdfn(); +}; +// clickCmd returns a click handler that runs a cmd and shows its shortcut. +const clickCmd = (cmdfn, shortcuts) => { + return async function click() { + shortcutCmd(cmdfn, shortcuts); + }; +}; +// enterCmd returns a keydown handler that runs a cmd when Enter is pressed and shows its shortcut. +const enterCmd = (cmdfn, shortcuts) => { + return async function keydown(e) { + if (e.key === 'Enter') { + e.stopPropagation(); + shortcutCmd(cmdfn, shortcuts); + } + }; +}; +// keyHandler returns a function that handles keyboard events for a map of +// shortcuts, calling the shortcut function if found. +const keyHandler = (shortcuts) => { + return async (k, e) => { + const fn = shortcuts[k]; + if (fn) { + e.preventDefault(); + e.stopPropagation(); + fn(); + } + }; +}; +// For attachment sizes. +const formatSize = (size) => size > 1024 * 1024 ? (size / (1024 * 1024)).toFixed(1) + 'mb' : Math.ceil(size / 1024) + 'kb'; +// Parse size as used in minsize: and maxsize: in the search bar. +const parseSearchSize = (s) => { + s = s.trim(); + if (!s) { + return ['', 0]; + } + const digits = s.match(/^([0-9]+)/)?.[1]; + if (!digits) { + return ['', 0]; + } + let num = parseInt(digits); + if (isNaN(num)) { + return ['', 0]; + } + const suffix = s.substring(digits.length).trim().toLowerCase(); + if (['b', 'kb', 'mb', 'gb'].includes(suffix)) { + return [digits + suffix, num * Math.pow(2, 10 * ['b', 'kb', 'mb', 'gb'].indexOf(suffix))]; + } + if (['k', 'm', 'g'].includes(suffix)) { + return [digits + suffix + 'b', num * Math.pow(2, 10 * (1 + ['k', 'm', 'g'].indexOf(suffix)))]; + } + return ['', 0]; +}; +// JS date does not allow months and days as single digit, it requires a 0 +// prefix in those cases, so fix up such dates. +const fixDate = (dt) => { + const t = dt.split('-'); + if (t.length !== 3) { + return dt; + } + if (t[1].length === 1) { + t[1] = '0' + t[1]; + } + if (t[2].length === 1) { + t[2] = '0' + t[2]; + } + return t.join('-'); +}; +// Parse date and/or time, for use in searchbarElem with start: and end:. +const parseSearchDateTime = (s, isstart) => { + const t = s.split('T', 2); + if (t.length === 2) { + const d = new Date(fixDate(t[0]) + 'T' + t[1]); + return d ? d.toJSON() : undefined; + } + else if (t.length === 1) { + if (isNaN(Date.parse(fixDate(t[0])))) { + const d = new Date(fixDate(t[0])); + if (!isstart) { + d.setDate(d.getDate() + 1); + } + return d.toJSON(); + } + else { + const tm = t[0]; + const now = new Date(); + const pad0 = (v) => v <= 9 ? '0' + v : '' + v; + const d = new Date([now.getFullYear(), pad0(now.getMonth() + 1), pad0(now.getDate())].join('-') + 'T' + tm); + return d ? d.toJSON() : undefined; + } + } + return undefined; +}; +const dquote = (s) => '"' + s.replaceAll('"', '""') + '"'; +const needsDquote = (s) => /[ \t"]/.test(s); +const packToken = (t) => (t[0] ? '-' : '') + (t[1] ? t[1] + ':' : '') + (t[2] || needsDquote(t[3]) ? dquote(t[3]) : t[3]); +// Parse the text from the searchbarElem into tokens. All input is valid. +const parseSearchTokens = (s) => { + if (!s) { + return []; + } + const l = []; // Tokens we gathered. + let not = false; + let quoted = false; // If double quote was seen. + let quoteend = false; // Possible closing quote seen. Can also be escaped quote. + let t = ''; // Current token. We only keep non-empty tokens. + let tquoted = false; // If t started out quoted. + const add = () => { + if (t && (tquoted || !t.includes(':'))) { + l.push([not, '', tquoted, t]); + } + else if (t) { + const tag = t.split(':', 1)[0]; + l.push([not, tag, tquoted, t.substring(tag.length + 1)]); + } + t = ''; + quoted = false; + quoteend = false; + tquoted = false; + not = false; + }; + [...s].forEach(c => { + if (quoteend) { + if (c === '"') { + t += '"'; + quoteend = false; + } + else if (t) { + add(); + } + } + else if (quoted && c == '"') { + quoteend = true; + } + else if (c === '"') { + quoted = true; + if (!t) { + tquoted = true; + } + } + else if (!quoted && (c === ' ' || c === '\t')) { + add(); + } + else if (c === '-' && !t && !tquoted && !not) { + not = true; + } + else { + t += c; + } + }); + add(); + return l; +}; +// returns a filter with empty/zero required fields. +const newFilter = () => { + return { + MailboxID: 0, + MailboxChildrenIncluded: false, + MailboxName: '', + Attachments: api.AttachmentType.AttachmentIndifferent, + SizeMin: 0, + SizeMax: 0, + }; +}; +const newNotFilter = () => { + return { + Attachments: api.AttachmentType.AttachmentIndifferent, + }; +}; +// Parse search bar into filters that we can use to populate the form again, or +// send to the server. +const parseSearch = (searchquery, mailboxlistView) => { + const tokens = parseSearchTokens(searchquery); + const fpos = newFilter(); + fpos.MailboxID = -1; // All mailboxes excluding Trash/Junk/Rejects. + const notf = newNotFilter(); + const strs = { Oldest: '', Newest: '', SizeMin: '', SizeMax: '' }; + tokens.forEach(t => { + const [not, tag, _, s] = t; + const f = not ? notf : fpos; + if (!not) { + if (tag === 'mb' || tag === 'mailbox') { + const mb = mailboxlistView.findMailboxByName(s); + if (mb) { + fpos.MailboxID = mb.ID; + } + else if (s === '') { + fpos.MailboxID = 0; // All mailboxes, including Trash/Junk/Rejects. + } + else { + fpos.MailboxName = s; + fpos.MailboxID = 0; + } + return; + } + else if (tag == 'submb') { + fpos.MailboxChildrenIncluded = true; + return; + } + else if (tag === 'start') { + const dt = parseSearchDateTime(s, true); + if (dt) { + fpos.Oldest = new Date(dt); + strs.Oldest = s; + return; + } + } + else if (tag === 'end') { + const dt = parseSearchDateTime(s, false); + if (dt) { + fpos.Newest = new Date(dt); + strs.Newest = s; + return; + } + } + else if (tag === 'a' || tag === 'attachments') { + if (s === 'none' || s === 'any' || s === 'image' || s === 'pdf' || s === 'archive' || s === 'zip' || s === 'spreadsheet' || s === 'document' || s === 'presentation') { + fpos.Attachments = s; + return; + } + } + else if (tag === 'h' || tag === 'header') { + const k = s.split(':')[0]; + const v = s.substring(k.length + 1); + if (!fpos.Headers) { + fpos.Headers = [[k, v]]; + } + else { + fpos.Headers.push([k, v]); + } + return; + } + else if (tag === 'minsize') { + const [str, size] = parseSearchSize(s); + if (str) { + fpos.SizeMin = size; + strs.SizeMin = str; + return; + } + } + else if (tag === 'maxsize') { + const [str, size] = parseSearchSize(s); + if (str) { + fpos.SizeMax = size; + strs.SizeMax = str; + return; + } + } + } + if (tag === 'f' || tag === 'from') { + f.From = f.From || []; + f.From.push(s); + return; + } + else if (tag === 't' || tag === 'to') { + f.To = f.To || []; + f.To.push(s); + return; + } + else if (tag === 's' || tag === 'subject') { + f.Subject = f.Subject || []; + f.Subject.push(s); + return; + } + else if (tag === 'l' || tag === 'label') { + f.Labels = f.Labels || []; + f.Labels.push(s); + return; + } + f.Words = f.Words || []; + f.Words.push((tag ? tag + ':' : '') + s); + }); + return [fpos, notf, strs]; +}; +// Errors in catch statements are of type unknown, we normally want its +// message. +const errmsg = (err) => '' + (err.message || '(no error message)'); +// Return keydown handler that creates or updates the datalist of its target with +// autocompletion addresses. The tab key completes with the first selection. +let datalistgen = 1; +const newAddressComplete = () => { + let datalist; + let completeMatches; + let completeSearch; + let completeFull; + return async function keydown(e) { + const target = e.target; + if (!datalist) { + datalist = dom.datalist(attr.id('list-' + datalistgen++)); + target.parentNode.insertBefore(datalist, target); + target.setAttribute('list', datalist.id); + } + const search = target.value; + if (e.key === 'Tab') { + const matches = (completeMatches || []).filter(s => s.includes(search)); + if (matches.length > 0) { + target.value = matches[0]; + return; + } + else if ((completeMatches || []).length === 0 && !search) { + return; + } + } + if (completeSearch && search.includes(completeSearch) && completeFull) { + dom._kids(datalist, (completeMatches || []).filter(s => s.includes(search)).map(s => dom.option(s))); + return; + } + else if (search === completeSearch) { + return; + } + try { + [completeMatches, completeFull] = await withStatus('Autocompleting addresses', client.CompleteRecipient(search)); + completeSearch = search; + dom._kids(datalist, (completeMatches || []).map(s => dom.option(s))); + } + catch (err) { + log('autocomplete error', errmsg(err)); + } + }; +}; +// Characters we display in the message list for flags set for a message. +// todo: icons would be nice to have instead. +const flagchars = { + Replied: 'r', + Flagged: '!', + Forwarded: 'f', + Junk: 'j', + Deleted: 'D', + Draft: 'd', + Phishing: 'p', +}; +const flagList = (m, mi) => { + let l = []; + const flag = (v, char, name) => { + if (v) { + l.push([name, char]); + } + }; + flag(m.Answered, 'r', 'Replied/answered'); + flag(m.Flagged, '!', 'Flagged'); + flag(m.Forwarded, 'f', 'Forwarded'); + flag(m.Junk, 'j', 'Junk'); + flag(m.Deleted, 'D', 'Deleted, used in IMAP, message will likely be removed soon.'); + flag(m.Draft, 'd', 'Draft'); + flag(m.Phishing, 'p', 'Phishing'); + flag(!m.Junk && !m.Notjunk, '?', 'Unclassified, neither junk nor not junk: message does not contribute to spam classification of new incoming messages'); + flag(mi.Attachments && mi.Attachments.length > 0 ? true : false, 'a', 'Has at least one attachment'); + return l.map(t => dom.span(dom._class('msgitemflag'), t[1], attr.title(t[0]))); +}; +// Turn filters from the search bar into filters with the refine filters (buttons +// above message list) applied, to send to the server in a request. The original +// filters are not modified. +const refineFilters = (f, notf) => { + const refine = settings.refine; + if (refine) { + f = { ...f }; + notf = { ...notf }; + if (refine === 'unread') { + notf.Labels = [...(notf.Labels || [])]; + notf.Labels = (notf.Labels || []).concat(['\\Seen']); + } + else if (refine === 'read') { + f.Labels = [...(f.Labels || [])]; + f.Labels = (f.Labels || []).concat(['\\Seen']); + } + else if (refine === 'attachments') { + f.Attachments = 'any'; + } + else if (refine.startsWith('label:')) { + f.Labels = [...(f.Labels || [])]; + f.Labels = (f.Labels || []).concat([refine.substring('label:'.length)]); + } + } + return [f, notf]; +}; +// For dragging the splitter bars. This function should be called on mousedown. e +// is the mousedown event. Move is the function to call when the bar was dragged, +// typically adjusting styling, e.g. absolutely positioned offsets, possibly based +// on the event.clientX and element bounds offset. +const startDrag = (e, move) => { + if (e.buttons === 1) { + e.preventDefault(); + e.stopPropagation(); + const stop = () => { + document.body.removeEventListener('mousemove', move); + document.body.removeEventListener('mouseup', stop); + }; + document.body.addEventListener('mousemove', move); + document.body.addEventListener('mouseup', stop); + } +}; +// Returns two handler functions: one for focus that sets a placeholder on the +// target element, and one for blur that restores/clears it again. Keeps forms uncluttered, +// only showing contextual help just before you start typing. +const focusPlaceholder = (s) => { + let orig = ''; + return [ + function focus(e) { + const target = e.target; + orig = target.getAttribute('placeholder') || ''; + target.setAttribute('placeholder', s); + }, + function blur(e) { + const target = e.target; + if (orig) { + target.setAttribute('placeholder', orig); + } + else { + target.removeAttribute('placeholder'); + } + }, + ]; +}; +// Parse a location hash into search terms (if any), selected message id (if +// any) and filters. +// Optional message id at the end, with ",". +// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1" +const parseLocationHash = (mailboxlistView) => { + let hash = decodeURIComponent((window.location.hash || '#').substring(1)); + const m = hash.match(/,([0-9]+)$/); + let msgid = 0; + if (m) { + msgid = parseInt(m[1]); + hash = hash.substring(0, hash.length - (','.length + m[1].length)); + } + let initmailbox, initsearch; + if (hash.startsWith('search ')) { + initsearch = hash.substring('search '.length).trim(); + } + let f, notf; + if (initsearch) { + [f, notf,] = parseSearch(initsearch, mailboxlistView); + } + else { + initmailbox = hash; + if (!initmailbox) { + initmailbox = 'Inbox'; + } + f = newFilter(); + const mb = mailboxlistView.findMailboxByName(initmailbox); + if (mb) { + f.MailboxID = mb.ID; + } + else { + f.MailboxName = initmailbox; + } + notf = newNotFilter(); + } + return [initsearch, msgid, f, notf]; +}; +// When API calls are made, we start displaying what we're doing after 1 second. +// Hopefully the command has completed by then, but slow operations, or in case of +// high latency, we'll start showing it. And hide it again when done. This should +// give a non-cluttered instant feeling most of the time, but informs the user when +// needed. +let statusElem; +const withStatus = async (action, promise, disablable, noAlert) => { + let elem; + let id = window.setTimeout(() => { + elem = dom.span(action + '...'); + statusElem.appendChild(elem); + id = 0; + }, 1000); + // Could be the element we are going to disable, causing it to lose its focus. We'll restore afterwards. + let origFocus = document.activeElement; + try { + if (disablable) { + disablable.disabled = true; + } + return await promise; + } + catch (err) { + if (id) { + window.clearTimeout(id); + id = 0; + } + // Generated by client for aborted requests, e.g. for api.ParsedMessage when loading a message. + if (err.code === 'sherpa:aborted') { + throw err; + } + if (!noAlert) { + window.alert('Error: ' + action + ': ' + errmsg(err)); + } + // We throw the error again. The ensures callers that await their withStatus call + // won't continue executing. We have a global handler for uncaught promises, but it + // only handles javascript-level errors, not api call/operation errors. + throw err; + } + finally { + if (disablable) { + disablable.disabled = false; + } + if (origFocus && document.activeElement !== origFocus && origFocus instanceof HTMLElement) { + origFocus.focus(); + } + if (id) { + window.clearTimeout(id); + } + if (elem) { + elem.remove(); + } + } +}; +// Popover shows kids in a div on top of a mostly transparent overlay on top of +// the document. If transparent is set, the div the kids are in will not get a +// white background. If focus is set, it will be called after adding the +// popover change focus to it, instead of focusing the popover itself. +// Popover returns a function that removes the popover. Clicking the +// transparent overlay, or hitting Escape, closes the popover. +// The div with the kids is positioned around mouse event e, preferably +// towards the right and bottom. But when the position is beyond 2/3's of the +// width or height, it is positioned towards the other direction. The div with +// kids is scrollable if needed. +const popover = (target, opts, ...kids) => { + const origFocus = document.activeElement; + const pos = target.getBoundingClientRect(); + const close = () => { + if (!root.parentNode) { + return; + } + root.remove(); + if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { + origFocus.focus(); + } + }; + const posx = opts.fullscreen ? + style({ left: 0, right: 0 }) : + (pos.x < window.innerWidth / 3 ? + style({ left: '' + (pos.x) + 'px' }) : + style({ right: '' + (window.innerWidth - pos.x - pos.width) + 'px' })); + const posy = opts.fullscreen ? + style({ top: 0, bottom: 0 }) : + (pos.y + pos.height > window.innerHeight * 2 / 3 ? + style({ bottom: '' + (window.innerHeight - (pos.y - 1)) + 'px', maxHeight: '' + (pos.y - 1 - 10) + 'px' }) : + style({ top: '' + (pos.y + pos.height + 1) + 'px', maxHeight: '' + (window.innerHeight - (pos.y + pos.height + 1) - 10) + 'px' })); + let content; + const root = dom.div(style({ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: 'rgba(0, 0, 0, 0.2)' }), function click(e) { + e.stopPropagation(); + close(); + }, function keydown(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + close(); + } + }, content = dom.div(attr.tabindex('0'), style({ + position: 'absolute', + overflowY: 'auto', + }), posx, posy, opts.transparent ? [] : [ + style({ + backgroundColor: 'white', + padding: '1em', + borderRadius: '.15em', + boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', + }), + function click(e) { + e.stopPropagation(); + }, + ], ...kids)); + document.body.appendChild(root); + const first = root.querySelector('input, select, textarea, button'); + if (first && first instanceof HTMLElement) { + first.focus(); + } + else { + content.focus(); + } + return close; +}; +// Popup shows kids in a centered div with white background on top of a +// transparent overlay on top of the window. Clicking the overlay or hitting +// Escape closes the popup. Scrollbars are automatically added to the div with +// kids. Returns a function that removes the popup. +// While a popup is open, no global keyboard shortcuts are handled. Popups get +// to handle keys themselves, e.g. for scrolling. +let popupOpen = false; +const popup = (...kids) => { + const origFocus = document.activeElement; + const close = () => { + if (!root.parentNode) { + return; + } + popupOpen = false; + root.remove(); + if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { + origFocus.focus(); + } + }; + let content; + const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0, 0, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup }), function keydown(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + close(); + } + }, function click(e) { + e.stopPropagation(); + close(); + }, content = dom.div(attr.tabindex('0'), style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto' }), function click(e) { + e.stopPropagation(); + }, kids)); + popupOpen = true; + document.body.appendChild(root); + content.focus(); + return close; +}; +// Show help popup, with shortcuts and basic explanation. +const cmdHelp = async () => { + const remove = popup(style({ padding: '1em 1em 2em 1em' }), dom.h1('Help and keyboard shortcuts'), dom.div(style({ display: 'flex' }), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({ margin: '0' })))), [ + ['c', 'compose new message'], + ['/', 'search'], + ['i', 'open inbox'], + ['?', 'help'], + ['ctrl ?', 'tooltip for focused element'], + ['M', 'focus message'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Mailbox', style({ margin: '0' })))), [ + ['←', 'collapse'], + ['→', 'expand'], + ['b', 'show more actions'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({ margin: '1ex 0 0 0' })))), dom.tr(dom.td('↓', ', j'), dom.td('down one message'), dom.td(attr.rowspan('6'), style({ color: '#888', borderLeft: '2px solid #ddd', paddingLeft: '.5em' }), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection')), [ + [['↑', ', k'], 'up one message'], + ['PageDown, l', 'down one screen'], + ['PageUp, h', 'up one screen'], + ['End, .', 'to last message'], + ['Home, ,', 'to first message'], + ['Space', 'toggle selection of message'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), [ + ['', ''], + ['d, Delete', 'move to trash folder'], + ['D', 'delete permanently'], + ['q', 'move to junk folder'], + ['n', 'mark not junk'], + ['a', 'move to archive folder'], + ['u', 'mark unread'], + ['m', 'mark read'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Compose', style({ margin: '1ex 0 0 0' })))), [ + ['ctrl Enter', 'send message'], + ['ctrl w', 'cancel message'], + ['ctlr O', 'add To'], + ['ctrl C', 'add Cc'], + ['ctrl B', 'add Bcc'], + ['ctrl Y', 'add Reply-To'], + ['ctrl -', 'remove current address'], + ['ctrl +', 'add address of same type'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))))), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Message', style({ margin: '0' })))), [ + ['r', 'reply or list reply'], + ['R', 'reply all'], + ['f', 'forward message'], + ['v', 'view attachments'], + ['T', 'view text version'], + ['X', 'view HTML version'], + ['o', 'open message in new tab'], + ['O', 'show raw message'], + ['ctrl p', 'print message'], + ['I', 'toggle internals'], + ['ctrl I', 'toggle all headers'], + ['alt k, alt ArrowUp', 'scroll up'], + ['alt j, alt ArrowDown', 'scroll down'], + ['alt K', 'scroll to top'], + ['alt J', 'scroll to end'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(dom.h2('Attachments', style({ margin: '1ex 0 0 0' })))), [ + ['left, h', 'previous attachment'], + ['right, l', 'next attachment'], + ['0', 'first attachment'], + ['$', 'next attachment'], + ['d', 'download'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1])))), dom.div(style({ marginTop: '2ex', marginBottom: '1ex' }), dom.span('Underdotted text', attr.title('Underdotted text shows additional information on hover.')), ' show an explanation or additional information when hovered.'), dom.div(style({ marginBottom: '1ex' }), 'Multiple messages can be selected by clicking messages while holding the control and/or shift keys. Dragging messages and dropping them on a mailbox moves the messages to that mailbox.'), dom.div(style({ marginBottom: '1ex' }), 'Text that changes ', dom.span(attr.title('Unicode blocks, e.g. from basic latin to cyrillic, or to emoticons.'), '"character groups"'), ' without whitespace has an ', dom.span(dom._class('scriptswitch'), 'orange underline'), ', which can be a sign of an intent to mislead (e.g. phishing).'), settings.showShortcuts ? + dom.div(style({ marginTop: '2ex' }), 'Shortcut keys for mouse operation are shown in the bottom left. ', dom.clickbutton('Disable', function click() { + settingsPut({ ...settings, showShortcuts: false }); + remove(); + cmdHelp(); + })) : + dom.div(style({ marginTop: '2ex' }), 'Shortcut keys for mouse operation are currently not shown. ', dom.clickbutton('Enable', function click() { + settingsPut({ ...settings, showShortcuts: true }); + remove(); + cmdHelp(); + })), dom.div(style({ marginTop: '2ex' }), 'Mox is open source email server software, this is version ' + moxversion + '. Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'), '.')))); +}; +// Show tooltips for either the focused element, or otherwise for all elements +// that aren't reachable with tabindex and aren't marked specially to prevent +// them from showing up (e.g. dates in the msglistview, which can also been +// seen by opening a message). +const cmdTooltip = async () => { + let elems = []; + if (document.activeElement && document.activeElement !== document.body) { + if (document.activeElement.getAttribute('title')) { + elems = [document.activeElement]; + } + elems = [...elems, ...document.activeElement.querySelectorAll('[title]')]; + } + if (elems.length === 0) { + // Find elements without a parent with tabindex=0. + const seen = {}; + elems = [...document.body.querySelectorAll('[title]:not(.notooltip):not(.silenttitle)')].filter(e => { + const title = e.getAttribute('title') || ''; + if (seen[title]) { + return false; + } + seen[title] = true; + return !(e instanceof HTMLInputElement || e instanceof HTMLSelectElement || e instanceof HTMLButtonElement || e instanceof HTMLTextAreaElement || e instanceof HTMLAnchorElement || e.getAttribute('tabindex') || e.closest('[tabindex]')); + }); + } + if (elems.length === 0) { + window.alert('No active elements with tooltips found.'); + return; + } + popover(document.body, { transparent: true, fullscreen: true }, ...elems.map(e => { + const title = e.getAttribute('title') || ''; + const pos = e.getBoundingClientRect(); + return dom.div(style({ position: 'absolute', backgroundColor: 'black', color: 'white', borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em' }), pos.x < window.innerWidth / 3 ? + style({ left: '' + (pos.x) + 'px' }) : + style({ right: '' + (window.innerWidth - pos.x - pos.width) + 'px' }), pos.y + pos.height > window.innerHeight * 2 / 3 ? + style({ bottom: '' + (window.innerHeight - (pos.y - 2)) + 'px', maxHeight: '' + (pos.y - 2) + 'px' }) : + style({ top: '' + (pos.y + pos.height + 2) + 'px', maxHeight: '' + (window.innerHeight - (pos.y + pos.height + 2)) + 'px' }), title); + })); +}; +let composeView = null; +const compose = (opts) => { + log('compose', opts); + if (composeView) { + // todo: should allow multiple + window.alert('Can only compose one message at a time.'); + return; + } + let fieldset; + let from; + let customFrom = null; + let subject; + let body; + let attachments; + let toBtn, ccBtn, bccBtn, replyToBtn, customFromBtn; + let replyToCell, toCell, ccCell, bccCell; // Where we append new address views. + let toRow, replyToRow, ccRow, bccRow; // We show/hide rows as needed. + let toViews = [], replytoViews = [], ccViews = [], bccViews = []; + let forwardAttachmentViews = []; + const cmdCancel = async () => { + composeElem.remove(); + composeView = null; + }; + const submit = async () => { + const files = await new Promise((resolve, reject) => { + const l = []; + if (attachments.files && attachments.files.length === 0) { + resolve(l); + return; + } + [...attachments.files].forEach(f => { + const fr = new window.FileReader(); + fr.addEventListener('load', () => { + l.push({ Filename: f.name, DataURI: fr.result }); + if (attachments.files && l.length == attachments.files.length) { + resolve(l); + } + }); + fr.addEventListener('error', () => { + reject(fr.error); + }); + fr.readAsDataURL(f); + }); + }); + let replyTo = ''; + if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) { + replyTo = replytoViews[0].input.value; + } + const forwardAttachmentPaths = forwardAttachmentViews.filter(v => v.checkbox.checked).map(v => v.path); + const message = { + From: customFrom ? customFrom.value : from.value, + To: toViews.map(v => v.input.value).filter(s => s), + Cc: ccViews.map(v => v.input.value).filter(s => s), + Bcc: bccViews.map(v => v.input.value).filter(s => s), + ReplyTo: replyTo, + UserAgent: 'moxwebmail/' + moxversion, + Subject: subject.value, + TextBody: body.value, + Attachments: files, + ForwardAttachments: forwardAttachmentPaths.length === 0 ? { MessageID: 0, Paths: [] } : { MessageID: opts.attachmentsMessageItem.Message.ID, Paths: forwardAttachmentPaths }, + IsForward: opts.isForward || false, + ResponseMessageID: opts.responseMessageID || 0, + }; + await client.MessageSubmit(message); + cmdCancel(); + }; + const cmdSend = async () => { + await withStatus('Sending email', submit(), fieldset); + }; + const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow); }; + const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow); }; + const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow); }; + const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true); }; + const cmdCustomFrom = async () => { + if (customFrom) { + return; + } + customFrom = dom.input(attr.value(from.value), attr.required(''), focusPlaceholder('Jane ')); + from.replaceWith(customFrom); + customFromBtn.remove(); + }; + const shortcuts = { + 'ctrl Enter': cmdSend, + 'ctrl w': cmdCancel, + 'ctrl O': cmdAddTo, + 'ctrl C': cmdAddCc, + 'ctrl B': cmdAddBcc, + 'ctrl Y': cmdReplyTo, + // ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add. + }; + const newAddrView = (addr, views, btn, cell, row, single) => { + if (single && views.length !== 0) { + return; + } + let input; + const root = dom.span(input = dom.input(focusPlaceholder('Jane '), style({ width: 'auto' }), attr.value(addr), newAddressComplete(), function keydown(e) { + if (e.key === '-' && e.ctrlKey) { + remove(); + } + else if (e.key === '=' && e.ctrlKey) { + newAddrView('', views, btn, cell, row, single); + } + else { + return; + } + e.preventDefault(); + e.stopPropagation(); + }), ' ', dom.clickbutton('-', style({ padding: '0 .25em' }), attr.arialabel('Remove address.'), attr.title('Remove address.'), function click() { + remove(); + if (single && views.length === 0) { + btn.style.display = ''; + } + }), ' '); + const remove = () => { + const i = views.indexOf(v); + views.splice(i, 1); + root.remove(); + if (views.length === 0) { + row.style.display = 'none'; + } + if (views.length === 0 && single) { + btn.style.display = ''; + } + let next = cell.querySelector('input'); + if (!next) { + let tr = row.nextSibling; + while (tr) { + next = tr.querySelector('input'); + if (!next && tr.nextSibling) { + tr = tr.nextSibling; + continue; + } + break; + } + } + if (next) { + next.focus(); + } + }; + const v = { root: root, input: input }; + views.push(v); + cell.appendChild(v.root); + row.style.display = ''; + if (single) { + btn.style.display = 'none'; + } + input.focus(); + return v; + }; + let noAttachmentsWarning; + const checkAttachments = () => { + const missingAttachments = !attachments.files?.length && !forwardAttachmentViews.find(v => v.checkbox.checked) && !!body.value.split('\n').find(s => !s.startsWith('>') && s.match(/attach(ed|ment)/)); + noAttachmentsWarning.style.display = missingAttachments ? '' : 'none'; + }; + const normalizeUser = (a) => { + let user = a.User; + const domconf = domainAddressConfigs[a.Domain.ASCII]; + const localpartCatchallSeparator = domconf.LocalpartCatchallSeparator; + if (localpartCatchallSeparator) { + user = user.split(localpartCatchallSeparator)[0]; + } + const localpartCaseSensitive = domconf.LocalpartCaseSensitive; + if (!localpartCaseSensitive) { + user = user.toLowerCase(); + } + return user; + }; + // Find own address matching the specified address, taking wildcards, localpart + // separators and case-sensitivity into account. + const addressSelf = (addr) => { + return accountAddresses.find(a => a.Domain.ASCII === addr.Domain.ASCII && (a.User === '' || normalizeUser(a) == normalizeUser(addr))); + }; + let haveFrom = false; + const fromOptions = accountAddresses.map(a => { + const selected = opts.from && opts.from.length === 1 && equalAddress(a, opts.from[0]) || loginAddress && equalAddress(a, loginAddress) && (!opts.from || envelopeIdentity(opts.from)); + log('fromOptions', a, selected, loginAddress, equalAddress(a, loginAddress)); + const o = dom.option(formatAddressFull(a), selected ? attr.selected('') : []); + if (selected) { + haveFrom = true; + } + return o; + }); + if (!haveFrom && opts.from && opts.from.length === 1) { + const a = addressSelf(opts.from[0]); + if (a) { + const fromAddr = { Name: a.Name, User: opts.from[0].User, Domain: a.Domain }; + const o = dom.option(formatAddressFull(fromAddr), attr.selected('')); + fromOptions.unshift(o); + } + } + const composeElem = dom.div(style({ + position: 'fixed', + bottom: '1ex', + right: '1ex', + zIndex: zindexes.compose, + backgroundColor: 'white', + boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', + border: '1px solid #ccc', + padding: '1em', + minWidth: '40em', + maxWidth: '70em', + width: '40%', + borderRadius: '.25em', + }), dom.form(fieldset = dom.fieldset(dom.table(style({ width: '100%' }), dom.tr(dom.td(style({ textAlign: 'right', color: '#555' }), dom.span('From:')), dom.td(dom.clickbutton('Cancel', style({ float: 'right' }), attr.title('Close window, discarding message.'), clickCmd(cmdCancel, shortcuts)), from = dom.select(attr.required(''), style({ width: 'auto' }), fromOptions), ' ', toBtn = dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', ccBtn = dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', bccBtn = dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', replyToBtn = dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', customFromBtn = dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts)))), toRow = dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555' })), toCell = dom.td(style({ width: '100%' }))), replyToRow = dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555' })), replyToCell = dom.td(style({ width: '100%' }))), ccRow = dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555' })), ccCell = dom.td(style({ width: '100%' }))), bccRow = dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555' })), bccCell = dom.td(style({ width: '100%' }))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555' })), dom.td(style({ width: '100%' }), subject = dom.input(focusPlaceholder('subject...'), attr.value(opts.subject || ''), attr.required(''), style({ width: '100%' }))))), body = dom.textarea(dom._class('mono'), attr.rows('15'), style({ width: '100%' }), opts.body || '', opts.body && !opts.isForward ? prop({ selectionStart: opts.body.length, selectionEnd: opts.body.length }) : [], function keyup(e) { + if (e.key === 'Enter') { + checkAttachments(); + } + }), !(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div(style({ margin: '.5em 0' }), 'Forward attachments: ', forwardAttachmentViews = (opts.attachmentsMessageItem?.Attachments || []).map(a => { + const filename = a.Filename || '(unnamed)'; + const size = formatSize(a.Part.DecodedSize); + const checkbox = dom.input(attr.type('checkbox'), function change() { checkAttachments(); }); + const root = dom.label(checkbox, ' ' + filename + ' ', dom.span('(' + size + ') ', style({ color: '#666' }))); + const v = { + path: a.Path || [], + root: root, + checkbox: checkbox + }; + return v; + }), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) { + forwardAttachmentViews.forEach(v => v.checkbox.checked = e.target.checked); + }), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.div(style({ margin: '1ex 0' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.submitbutton('Send')), async function submit(e) { + e.preventDefault(); + shortcutCmd(cmdSend, shortcuts); + })); + (opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)); + (opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)); + (opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)); + if (opts.replyto) { + newAddrView(opts.replyto, replytoViews, replyToBtn, replyToCell, replyToRow, true); + } + if (!opts.cc || !opts.cc.length) { + ccRow.style.display = 'none'; + } + if (!opts.bcc || !opts.bcc.length) { + bccRow.style.display = 'none'; + } + if (!opts.replyto) { + replyToRow.style.display = 'none'; + } + document.body.appendChild(composeElem); + if (toViews.length > 0 && !toViews[0].input.value) { + toViews[0].input.focus(); + } + else { + body.focus(); + } + composeView = { + root: composeElem, + key: keyHandler(shortcuts), + }; + return composeView; +}; +// Show popover to edit labels for msgs. +const labelsPopover = (e, msgs, possibleLabels) => { + if (msgs.length === 0) { + return; // Should not happen. + } + const knownLabels = possibleLabels(); + const activeLabels = (msgs[0].Keywords || []).filter(kw => msgs.filter(m => (m.Keywords || []).includes(kw)).length === msgs.length); + const msgIDs = msgs.map(m => m.ID); + let fieldsetnew; + let newlabel; + const remove = popover(e.target, {}, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '1ex' }), knownLabels.map(l => dom.div(dom.label(dom.input(attr.type('checkbox'), activeLabels.includes(l) ? attr.checked('') : [], style({ marginRight: '.5em' }), attr.title('Add/remove this label to the message(s), leaving other labels unchanged.'), async function change(e) { + if (activeLabels.includes(l)) { + await withStatus('Removing label', client.FlagsClear(msgIDs, [l]), e.target); + activeLabels.splice(activeLabels.indexOf(l), 1); + } + else { + await withStatus('Adding label', client.FlagsAdd(msgIDs, [l]), e.target); + activeLabels.push(l); + } + }), ' ', dom.span(dom._class('keyword'), l))))), dom.hr(style({ margin: '2ex 0' })), dom.form(async function submit(e) { + e.preventDefault(); + await withStatus('Adding new label', client.FlagsAdd(msgIDs, [newlabel.value]), fieldsetnew); + remove(); + }, fieldsetnew = dom.fieldset(dom.div(newlabel = dom.input(focusPlaceholder('new-label'), attr.required(''), attr.title('New label to add/set on the message(s), must be lower-case, ascii-only, without spaces and without the following special characters: (){%*"\].')), ' ', dom.submitbutton('Add new label', attr.title('Add this label to the message(s), leaving other labels unchanged.')))))); +}; +// Show popover to move messages to a mailbox. +const movePopover = (e, mailboxes, msgs) => { + if (msgs.length === 0) { + return; // Should not happen. + } + let msgsMailboxID = (msgs[0].MailboxID && msgs.filter(m => m.MailboxID === msgs[0].MailboxID).length === msgs.length) ? msgs[0].MailboxID : 0; + const remove = popover(e.target, {}, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.25em' }), mailboxes.map(mb => dom.div(dom.clickbutton(mb.Name, mb.ID === msgsMailboxID ? attr.disabled('') : [], async function click() { + const msgIDs = msgs.filter(m => m.MailboxID !== mb.ID).map(m => m.ID); + await withStatus('Moving to mailbox', client.MessageMove(msgIDs, mb.ID)); + remove(); + }))))); +}; +// Make new MsgitemView, to be added to the list. othermb is set when this msgitem +// is displayed in a msglistView for other/multiple mailboxes, the mailbox name +// should be shown. +const newMsgitemView = (mi, msglistView, othermb) => { + // Timer to update the age of the message. + let ageTimer = 0; + // Show with a tag if we are in the cc/bcc headers, or - if none. + const identityTag = (s, title) => dom.span(dom._class('msgitemidentity'), s, attr.title(title)); + const identityHeader = []; + if (!envelopeIdentity(mi.Envelope.From || []) && !envelopeIdentity(mi.Envelope.To || [])) { + if (envelopeIdentity(mi.Envelope.CC || [])) { + identityHeader.push(identityTag('cc', 'You are in the CC header')); + } + if (envelopeIdentity(mi.Envelope.BCC || [])) { + identityHeader.push(identityTag('bcc', 'You are in the BCC header')); + } + // todo: don't include this if this is a message to a mailling list, based on list-* headers. + if (identityHeader.length === 0) { + identityHeader.push(identityTag('-', 'You are not in any To, From, CC, BCC header. Could message to a mailing list or Bcc without Bcc message header.')); + } + } + // If mailbox of message is not specified in filter (i.e. for mailbox list or + // search on the mailbox), we show it on the right-side of the subject. + const mailboxtag = []; + if (othermb) { + let name = othermb.Name; + if (name.length > 8 + 1 + 3 + 1 + 8 + 4) { + const t = name.split('/'); + const first = t[0]; + const last = t[t.length - 1]; + if (first.length + last.length <= 8 + 8) { + name = first + '/.../' + last; + } + else { + name = first.substring(0, 8) + '/.../' + last.substring(0, 8); + } + } + const e = dom.span(dom._class('msgitemmailbox'), name === othermb.Name ? [] : attr.title(othermb.Name), name); + mailboxtag.push(e); + } + const updateFlags = (mask, flags, keywords) => { + const maskobj = mask; + const flagsobj = flags; + const mobj = msgitemView.messageitem.Message; + for (const k in maskobj) { + if (maskobj[k]) { + mobj[k] = flagsobj[k]; + } + } + msgitemView.messageitem.Message.Keywords = keywords; + const elem = render(); + msgitemView.root.replaceWith(elem); + msgitemView.root = elem; + msglistView.redraw(msgitemView); + }; + const remove = () => { + msgitemView.root.remove(); + if (ageTimer) { + window.clearTimeout(ageTimer); + ageTimer = 0; + } + }; + const age = (date) => { + const r = dom.span(dom._class('notooltip'), attr.title(date.toString())); + const set = () => { + const nowSecs = new Date().getTime() / 1000; + let t = nowSecs - date.getTime() / 1000; + let negative = ''; + if (t < 0) { + negative = '-'; + 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]; + const suffix = ['y', 'mo', 'd', 'h', 'min']; + let s; + let nextSecs = 0; + for (let i = 0; i < periods.length; i++) { + const p = periods[i]; + if (t >= 2 * p || i == periods.length - 1) { + const n = Math.round(t / p); + s = '' + n + suffix[i]; + const prev = Math.floor(t / p); + nextSecs = Math.ceil((prev + 1) * p - t); + break; + } + } + if (t < 60) { + s = '<1min'; + nextSecs = 60 - t; + } + dom._kids(r, negative + s); + // note: Cannot have delays longer than 24.8 days due to storage as 32 bit in + // browsers. Session is likely closed/reloaded/refreshed before that time anyway. + if (nextSecs < 14 * 24 * 3600) { + ageTimer = window.setTimeout(set, nextSecs * 1000); + } + else { + ageTimer = 0; + } + }; + set(); + return r; + }; + const render = () => { + // Set by calling age(). + if (ageTimer) { + window.clearTimeout(ageTimer); + ageTimer = 0; + } + const m = msgitemView.messageitem.Message; + const keywords = (m.Keywords || []).map(kw => dom.span(dom._class('keyword'), kw)); + return dom.div(dom._class('msgitem'), attr.draggable('true'), function dragstart(e) { + e.dataTransfer.setData('application/vnd.mox.messages', JSON.stringify(msglistView.selected().map(miv => miv.messageitem.Message.ID))); + }, m.Seen ? [] : style({ fontWeight: 'bold' }), dom.div(dom._class('msgitemcell', 'msgitemflags'), flagList(m, msgitemView.messageitem)), dom.div(dom._class('msgitemcell', 'msgitemfrom'), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(dom._class('msgitemfromtext', 'silenttitle'), attr.title((mi.Envelope.From || []).map(a => formatAddressFull(a)).join(', ')), join((mi.Envelope.From || []).map(a => formatAddressShort(a)), () => ', ')), identityHeader)), dom.div(dom._class('msgitemcell', 'msgitemsubject'), dom.div(style({ display: 'flex', justifyContent: 'space-between', position: 'relative' }), dom.div(dom._class('msgitemsubjecttext'), mi.Envelope.Subject || '(no subject)', dom.span(dom._class('msgitemsubjectsnippet'), ' ' + mi.FirstLine)), dom.div(keywords, mailboxtag))), dom.div(dom._class('msgitemcell', 'msgitemage'), age(m.Received)), function click(e) { + e.preventDefault(); + e.stopPropagation(); + msglistView.click(msgitemView, e.ctrlKey, e.shiftKey); + }); + }; + const msgitemView = { + root: dom.div(), + messageitem: mi, + updateFlags: updateFlags, + remove: remove, + }; + msgitemView.root = render(); + return msgitemView; +}; +// If attachmentView is open, keyboard shortcuts go there. +let attachmentView = null; +// MsgView is the display of a single message. +// refineKeyword is called when a user clicks a label, to filter on those. +const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoaded, refineKeyword, parsedMessageOpt) => { + const mi = miv.messageitem; + const m = mi.Message; + const formatEmailAddress = (a) => a.User + '@' + a.Domain.ASCII; + const fromAddress = mi.Envelope.From && mi.Envelope.From.length === 1 ? formatEmailAddress(mi.Envelope.From[0]) : ''; + // Some operations below, including those that can be reached through shortcuts, + // need a parsed message. So we keep a promise around for having that parsed + // message. Operations always await it. Once we have the parsed message, the await + // completes immediately. + // Typescript doesn't know the function passed to new Promise runs immediately and + // has set the Resolve and Reject variables before returning. Is there a better + // solution? + let parsedMessageResolve = () => { }; + let parsedMessageReject = () => { }; + let parsedMessagePromise = new Promise((resolve, reject) => { + parsedMessageResolve = resolve; + parsedMessageReject = reject; + }); + const react = async (to, forward, all) => { + const pm = await parsedMessagePromise; + let body = ''; + const sel = window.getSelection(); + if (sel && sel.toString()) { + body = sel.toString(); + } + else if (pm.Texts && pm.Texts.length > 0) { + body = pm.Texts[0]; + } + body = body.replace(/\r/g, '').replace(/\n\n\n\n*/g, '\n\n').trim(); + if (forward) { + body = '\n\n---- Forwarded Message ----\n\n' + body; + } + else { + body = body.split('\n').map(line => '> ' + line).join('\n') + '\n\n'; + } + const subjectPrefix = forward ? 'Fwd:' : 'Re:'; + let subject = mi.Envelope.Subject || ''; + subject = (RegExp('^' + subjectPrefix, 'i').test(subject) ? '' : subjectPrefix + ' ') + subject; + const opts = { + from: mi.Envelope.To || undefined, + to: (to || []).map(a => formatAddress(a)), + cc: [], + bcc: [], + subject: subject, + body: body, + isForward: forward, + attachmentsMessageItem: forward ? mi : undefined, + responseMessageID: m.ID, + }; + if (all) { + opts.to = (to || []).concat((mi.Envelope.To || []).filter(a => !envelopeIdentity([a]))).map(a => formatAddress(a)); + opts.cc = (mi.Envelope.CC || []).map(a => formatAddress(a)); + opts.bcc = (mi.Envelope.BCC || []).map(a => formatAddress(a)); + } + compose(opts); + }; + const reply = async (all, toOpt) => { + await react(toOpt || ((mi.Envelope.ReplyTo || []).length > 0 ? mi.Envelope.ReplyTo : mi.Envelope.From) || null, false, all); + }; + const cmdForward = async () => { react([], true, false); }; + const cmdReplyList = async () => { + const pm = await parsedMessagePromise; + if (pm.ListReplyAddress) { + await reply(false, [pm.ListReplyAddress]); + } + }; + const cmdReply = async () => { await reply(false); }; + const cmdReplyAll = async () => { await reply(true); }; + const cmdPrint = async () => { + if (urlType) { + window.open('msg/' + m.ID + '/msg' + urlType + '#print', '_blank'); + } + }; + const cmdOpenNewTab = async () => { + if (urlType) { + window.open('msg/' + m.ID + '/msg' + urlType, '_blank'); + } + }; + const cmdOpenRaw = async () => { window.open('msg/' + m.ID + '/raw', '_blank'); }; + const cmdViewAttachments = async () => { + if (attachments.length > 0) { + view(attachments[0]); + } + }; + const cmdToggleHeaders = async () => { + settingsPut({ ...settings, showAllHeaders: !settings.showAllHeaders }); + loadHeaderDetails(await parsedMessagePromise); + }; + let textbtn, htmlbtn, htmlextbtn; + const activeBtn = (b) => { + for (const xb of [textbtn, htmlbtn, htmlextbtn]) { + xb.classList.toggle('active', xb === b); + } + }; + const cmdShowText = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return; + } + loadText(await parsedMessagePromise); + settingsPut({ ...settings, showHTML: false }); + activeBtn(textbtn); + }; + const cmdShowHTML = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return; + } + loadHTML(); + settingsPut({ ...settings, showHTML: true }); + activeBtn(htmlbtn); + }; + const cmdShowHTMLExternal = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return; + } + loadHTMLexternal(); + settingsPut({ ...settings, showHTML: true }); + activeBtn(htmlextbtn); + }; + const cmdShowHTMLCycle = async () => { + if (urlType === 'html') { + await cmdShowHTMLExternal(); + } + else { + await cmdShowHTML(); + } + }; + const cmdShowInternals = async () => { + const pm = await parsedMessagePromise; + const mimepart = (p) => dom.li((p.MediaType + '/' + p.MediaSubType).toLowerCase(), p.ContentTypeParams ? ' ' + JSON.stringify(p.ContentTypeParams) : [], p.Parts && p.Parts.length === 0 ? [] : dom.ul(style({ listStyle: 'disc', marginLeft: '1em' }), (p.Parts || []).map(pp => mimepart(pp)))); + popup(style({ display: 'flex', gap: '1em' }), dom.div(dom.h1('Mime structure'), dom.ul(style({ listStyle: 'disc', marginLeft: '1em' }), mimepart(pm.Part))), dom.div(style({ whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), dom.div(style({ whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t'))); + }; + const cmdUp = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollTop - 3 * msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth' }); }; + const cmdDown = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollTop + 3 * msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth' }); }; + const cmdHome = async () => { msgscrollElem.scrollTo({ top: 0 }); }; + const cmdEnd = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollHeight }); }; + const shortcuts = { + I: cmdShowInternals, + o: cmdOpenNewTab, + O: cmdOpenRaw, + 'ctrl p': cmdPrint, + f: cmdForward, + r: cmdReply, + R: cmdReplyAll, + v: cmdViewAttachments, + T: cmdShowText, + X: cmdShowHTMLCycle, + 'ctrl I': cmdToggleHeaders, + 'alt j': cmdDown, + 'alt k': cmdUp, + 'alt ArrowDown': cmdDown, + 'alt ArrowUp': cmdUp, + 'alt J': cmdEnd, + 'alt K': cmdHome, + // For showing shortcuts only, handled in msglistView. + a: msglistView.cmdArchive, + d: msglistView.cmdTrash, + D: msglistView.cmdDelete, + q: msglistView.cmdJunk, + n: msglistView.cmdMarkNotJunk, + u: msglistView.cmdMarkUnread, + m: msglistView.cmdMarkRead, + }; + let urlType; // text, html, htmlexternal; for opening in new tab/print + let msgbuttonElem, msgheaderElem, msgattachmentElem, msgmodeElem; + let msgheaderdetailsElem = null; // When full headers are visible, or some headers are requested through settings. + const msgmetaElem = dom.div(style({ backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', maxHeight: '90%', overflowY: 'auto' }), attr.role('region'), attr.arialabel('Buttons and headers for message'), msgbuttonElem = dom.div(), dom.div(attr.arialive('assertive'), msgheaderElem = dom.table(style({ marginBottom: '1ex', width: '100%' })), msgattachmentElem = dom.div(), msgmodeElem = dom.div())); + const msgscrollElem = dom.div(dom._class('pad', 'yscrollauto'), attr.role('region'), attr.arialabel('Message body'), style({ backgroundColor: 'white' })); + const msgcontentElem = dom.div(dom._class('scrollparent'), style({ flexGrow: '1' })); + const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID; + // Initially called with potentially null pm, once loaded called again with pm set. + const loadButtons = (pm) => { + dom._kids(msgbuttonElem, dom.div(dom._class('pad'), (!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ', (pm && pm.ListReplyAddress && formatEmailAddress(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ', (mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] : + dom.clickbutton('Reply all', attr.title('Compose a reply to all participants of this message.'), clickCmd(cmdReplyAll, shortcuts)), ' ', dom.clickbutton('Forward', attr.title('Compose a forwarding message, optionally including attachments.'), clickCmd(cmdForward, shortcuts)), ' ', dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(msglistView.cmdArchive, shortcuts)), ' ', m.MailboxID === trashMailboxID ? + dom.clickbutton('Delete', attr.title('Permanently delete message.'), clickCmd(msglistView.cmdDelete, shortcuts)) : + dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(msglistView.cmdTrash, shortcuts)), ' ', dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdJunk, shortcuts)), ' ', dom.clickbutton('Move to...', function click(e) { + movePopover(e, listMailboxes(), [m]); + }), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels.'), function click(e) { + labelsPopover(e, [m], possibleLabels); + }), ' ', dom.clickbutton('More...', attr.title('Show more actions.'), function click(e) { + popover(e.target, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right' }), [ + dom.clickbutton('Print', attr.title('Print message, opens in new tab and opens print dialog.'), clickCmd(cmdPrint, shortcuts)), + dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdMarkNotJunk, shortcuts)), + dom.clickbutton('Mark as read', clickCmd(msglistView.cmdMarkRead, shortcuts)), + dom.clickbutton('Mark as unread', clickCmd(msglistView.cmdMarkUnread, shortcuts)), + dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)), + dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)), + dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)), + ].map(b => dom.div(b)))); + }))); + }; + loadButtons(parsedMessageOpt || null); + loadMsgheaderView(msgheaderElem, miv.messageitem, refineKeyword); + const loadHeaderDetails = (pm) => { + if (msgheaderdetailsElem) { + msgheaderdetailsElem.remove(); + msgheaderdetailsElem = null; + } + if (!settings.showAllHeaders) { + return; + } + msgheaderdetailsElem = dom.table(style({ marginBottom: '1ex', width: '100%' }), Object.entries(pm.Headers || {}).sort().map(t => (t[1] || []).map(v => dom.tr(dom.td(t[0] + ':', style({ textAlign: 'right', color: '#555' })), dom.td(v))))); + msgattachmentElem.parentNode.insertBefore(msgheaderdetailsElem, msgattachmentElem); + }; + // From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types + const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', + ]; + const isImage = (a) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()); + const isPDF = (a) => (a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase() === 'application/pdf'; + const isViewable = (a) => isImage(a) || isPDF(a); + const attachments = (mi.Attachments || []); + let beforeViewFocus; + const view = (a) => { + if (!beforeViewFocus) { + beforeViewFocus = document.activeElement; + } + const pathStr = [0].concat(a.Path || []).join('.'); + const index = attachments.indexOf(a); + const cmdViewPrev = async () => { + if (index > 0) { + popupRoot.remove(); + view(attachments[index - 1]); + } + }; + const cmdViewNext = async () => { + if (index < attachments.length - 1) { + popupRoot.remove(); + view(attachments[index + 1]); + } + }; + const cmdViewFirst = async () => { + popupRoot.remove(); + view(attachments[0]); + }; + const cmdViewLast = async () => { + popupRoot.remove(); + view(attachments[attachments.length - 1]); + }; + const cmdViewClose = async () => { + popupRoot.remove(); + if (beforeViewFocus && beforeViewFocus instanceof HTMLElement && beforeViewFocus.parentNode) { + beforeViewFocus.focus(); + } + attachmentView = null; + beforeViewFocus = null; + }; + const attachShortcuts = { + h: cmdViewPrev, + ArrowLeft: cmdViewPrev, + l: cmdViewNext, + ArrowRight: cmdViewNext, + '0': cmdViewFirst, + '$': cmdViewLast, + Escape: cmdViewClose, + }; + let content; + const popupRoot = dom.div(style({ position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments }), function click(e) { + e.stopPropagation(); + cmdViewClose(); + }, attr.tabindex('0'), !(index > 0) ? [] : dom.div(style({ position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), style({ backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold' }), attr.title('To previous viewable attachment.'), '←'), attr.tabindex('0'), clickCmd(cmdViewPrev, attachShortcuts), enterCmd(cmdViewPrev, attachShortcuts)), dom.div(style({ textAlign: 'center', paddingBottom: '30px' }), dom.span(dom._class('pad'), function click(e) { + e.stopPropagation(); + }, style({ backgroundColor: 'white', borderRadius: '.25em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd' }), a.Filename || '(unnamed)', ' - ', formatSize(a.Part.DecodedSize), ' - ', dom.a('Download', attr.download(''), attr.href('msg/' + m.ID + '/download/' + pathStr), function click(e) { e.stopPropagation(); }))), isImage(a) ? + dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 5em' }), dom.img(attr.src('msg/' + m.ID + '/view/' + pathStr), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', margin: '0 30px' }))) : (isPDF(a) ? + dom.iframe(style({ flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em' }), attr.title('Attachment as PDF.'), attr.src('msg/' + m.ID + '/view/' + pathStr)) : + content = dom.div(function click(e) { + e.stopPropagation(); + }, style({ minWidth: '30em', padding: '2ex', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em', textAlign: 'center' }), dom.div(style({ marginBottom: '2ex' }), 'Attachment could be a binary file.'), dom.clickbutton('View as text', function click() { + content.replaceWith(dom.iframe(attr.title('Attachment shown as text, though it could be a binary file.'), style({ flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em' }), attr.src('msg/' + m.ID + '/viewtext/' + pathStr))); + }))), !(index < attachments.length - 1) ? [] : dom.div(style({ position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), style({ backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold' }), attr.title('To next viewable attachment.'), '→'), attr.tabindex('0'), clickCmd(cmdViewNext, attachShortcuts), enterCmd(cmdViewNext, attachShortcuts))); + document.body.appendChild(popupRoot); + popupRoot.focus(); + attachmentView = { key: keyHandler(attachShortcuts) }; + }; + dom._kids(msgattachmentElem, (mi.Attachments && mi.Attachments.length === 0) ? [] : dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', (mi.Attachments || []).map(a => { + const name = a.Filename || '(unnamed)'; + const viewable = isViewable(a); + const size = formatSize(a.Part.DecodedSize); + const eye = '👁'; + const dl = '⤓'; // \u2913, actually ⭳ \u2b73 would be better, but in fewer fonts (at least macos) + const dlurl = 'msg/' + m.ID + '/download/' + [0].concat(a.Path || []).join('.'); + const viewbtn = dom.clickbutton(eye, viewable ? ' ' + name : [], attr.title('View this file. Size: ' + size), style({ lineHeight: '1.5' }), function click() { + view(a); + }); + const dlbtn = dom.a(dom._class('button'), attr.download(''), attr.href(dlurl), dl, viewable ? [] : ' ' + name, attr.title('Download this file. Size: ' + size), style({ lineHeight: '1.5' })); + if (viewable) { + return [dom.span(dom._class('btngroup'), viewbtn, dlbtn), ' ']; + } + return [dom.span(dom._class('btngroup'), dlbtn, viewbtn), ' ']; + }), dom.a('Download all as zip', attr.download(''), style({ color: 'inherit' }), attr.href('msg/' + m.ID + '/attachments.zip'))))); + const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column' })); + dom._kids(root, msgmetaElem, msgcontentElem); + const loadText = (pm) => { + // We render text ourselves so we can make links clickable and get any selected + // text to use when writing a reply. We still set url so the text content can be + // opened in a separate tab, even though it will look differently. + urlType = 'text'; + const elem = dom.div(dom._class('mono'), style({ whiteSpace: 'pre-wrap' }), join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({ margin: '2ex 0' })))); + dom._kids(msgcontentElem); + dom._kids(msgscrollElem, elem); + dom._kids(msgcontentElem, msgscrollElem); + }; + const loadHTML = () => { + urlType = 'html'; + dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined, without external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + }; + const loadHTMLexternal = () => { + urlType = 'htmlexternal'; + dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined and with external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + }; + const mv = { + root: root, + messageitem: mi, + key: keyHandler(shortcuts), + aborter: { abort: () => { } }, + updateKeywords: (keywords) => { + mi.Message.Keywords = keywords; + loadMsgheaderView(msgheaderElem, miv.messageitem, refineKeyword); + }, + }; + (async () => { + let pm; + if (parsedMessageOpt) { + pm = parsedMessageOpt; + parsedMessageResolve(pm); + } + else { + const promise = withStatus('Loading message', client.withOptions({ aborter: mv.aborter }).ParsedMessage(m.ID)); + try { + pm = await promise; + } + catch (err) { + if (err instanceof Error) { + parsedMessageReject(err); + } + else { + parsedMessageReject(new Error('fetching message failed')); + } + throw err; + } + parsedMessageResolve(pm); + } + loadButtons(pm); + loadHeaderDetails(pm); + if (settings.showHeaders.length > 0) { + settings.showHeaders.forEach(k => { + const vl = pm.Headers?.[k]; + if (!vl || vl.length === 0) { + return; + } + vl.forEach(v => { + const e = dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(v)); + msgheaderElem.appendChild(e); + }); + }); + } + const htmlNote = 'In the HTML viewer, the following potentially dangerous functionality is disabled: submitting forms, starting a download from a link, navigating away from this page by clicking a link. If a link does not work, try explicitly opening it in a new tab.'; + const haveText = pm.Texts && pm.Texts.length > 0; + if (!haveText && !pm.HasHTML) { + dom._kids(msgcontentElem); + dom._kids(msgmodeElem, dom.div(dom._class('pad'), style({ borderTop: '1px solid #ccc' }), dom.span('No textual content', style({ backgroundColor: '#ffca91', padding: '0 .15em' })))); + } + else if (haveText && !pm.HasHTML) { + loadText(pm); + dom._kids(msgmodeElem); + } + else if (!haveText && pm.HasHTML) { + loadHTML(); + dom._kids(msgmodeElem, dom.div(dom._class('pad'), style({ borderTop: '1px solid #ccc' }), dom.span('HTML-only message', attr.title(htmlNote), style({ backgroundColor: '#ffca91', padding: '0 .15em' })))); + } + else { + dom._kids(msgmodeElem, dom.div(dom._class('pad'), style({ borderTop: '1px solid #ccc' }), dom.span(dom._class('btngroup'), textbtn = dom.clickbutton(settings.showHTML ? [] : dom._class('active'), 'Text', clickCmd(cmdShowText, shortcuts)), htmlbtn = dom.clickbutton(!settings.showHTML ? [] : dom._class('active'), 'HTML', attr.title(htmlNote), async function click() { + // Shortcuts has a function that cycles through html and htmlexternal. + showShortcut('X'); + await cmdShowHTML(); + }), htmlextbtn = dom.clickbutton('HTML with external resources', attr.title(htmlNote), clickCmd(cmdShowHTMLExternal, shortcuts))))); + if (settings.showHTML) { + loadHTML(); + } + else { + loadText(pm); + } + } + messageLoaded(); + if (!miv.messageitem.Message.Seen) { + window.setTimeout(async () => { + if (!miv.messageitem.Message.Seen && miv.messageitem.Message.ID === msglistView.activeMessageID()) { + await withStatus('Marking current message as read', client.FlagsAdd([miv.messageitem.Message.ID], ['\\seen'])); + } + }, 500); + } + })(); + return mv; +}; +const newMsglistView = (msgElem, listMailboxes, setLocationHash, otherMailbox, possibleLabels, scrollElemHeight, refineKeyword) => { + // These contain one msgitemView or an array of them. + // Zero or more selected msgitemViews. If there is a single message, its content is + // shown. If there are multiple, just the count is shown. These are in order of + // being added, not in order of how they are shown in the list. This is needed to + // handle selection changes with the shift key. + let selected = []; + // MsgitemView last interacted with, or the first when messages are loaded. Always + // set when there is a message. Used for shift+click to expand selection. + let focus = null; + let msgitemViews = []; + let msgView = null; + const cmdArchive = async () => { + const mb = listMailboxes().find(mb => mb.Archive); + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID); + await withStatus('Moving to archive mailbox', client.MessageMove(msgIDs, mb.ID)); + } + else { + window.alert('No mailbox configured for archiving yet.'); + } + }; + const cmdDelete = async () => { + if (!confirm('Are you sure you want to permanently delete?')) { + return; + } + await withStatus('Permanently deleting messages', client.MessageDelete(selected.map(miv => miv.messageitem.Message.ID))); + }; + const cmdTrash = async () => { + const mb = listMailboxes().find(mb => mb.Trash); + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID); + await withStatus('Moving to trash mailbox', client.MessageMove(msgIDs, mb.ID)); + } + else { + window.alert('No mailbox configured for trash yet.'); + } + }; + const cmdJunk = async () => { + const mb = listMailboxes().find(mb => mb.Junk); + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID); + await withStatus('Moving to junk mailbox', client.MessageMove(msgIDs, mb.ID)); + } + else { + window.alert('No mailbox configured for junk yet.'); + } + }; + const cmdMarkNotJunk = async () => { await withStatus('Marking as not junk', client.FlagsAdd(selected.map(miv => miv.messageitem.Message.ID), ['$notjunk'])); }; + const cmdMarkRead = async () => { await withStatus('Marking as read', client.FlagsAdd(selected.map(miv => miv.messageitem.Message.ID), ['\\seen'])); }; + const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(selected.map(miv => miv.messageitem.Message.ID), ['\\seen'])); }; + const shortcuts = { + d: cmdTrash, + Delete: cmdTrash, + D: cmdDelete, + q: cmdJunk, + a: cmdArchive, + n: cmdMarkNotJunk, + u: cmdMarkUnread, + m: cmdMarkRead, + }; + // Return active & focus state, and update the UI after changing state. + const state = () => { + const active = {}; + for (const miv of selected) { + active[miv.messageitem.Message.ID] = miv; + } + return { active: active, focus: focus }; + }; + const updateState = async (oldstate, initial, parsedMessageOpt) => { + // Set new focus & active classes. + const newstate = state(); + if (oldstate.focus !== newstate.focus) { + if (oldstate.focus) { + oldstate.focus.root.classList.toggle('focus', false); + } + if (newstate.focus) { + newstate.focus.root.classList.toggle('focus', true); + newstate.focus.root.scrollIntoView({ block: initial ? 'center' : 'nearest' }); + } + } + let activeChanged = false; + for (const id in oldstate.active) { + if (!newstate.active[id]) { + oldstate.active[id].root.classList.toggle('active', false); + activeChanged = true; + } + } + for (const id in newstate.active) { + if (!oldstate.active[id]) { + newstate.active[id].root.classList.toggle('active', true); + activeChanged = true; + } + } + if (initial && selected.length === 1) { + mlv.redraw(selected[0]); + } + if (activeChanged) { + if (msgView) { + msgView.aborter.abort(); + } + msgView = null; + if (selected.length === 0) { + dom._kids(msgElem); + } + else if (selected.length === 1) { + msgElem.classList.toggle('loading', true); + const loaded = () => { msgElem.classList.toggle('loading', false); }; + msgView = newMsgView(selected[0], mlv, listMailboxes, possibleLabels, loaded, refineKeyword, parsedMessageOpt); + dom._kids(msgElem, msgView); + } + else { + const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID; + const allTrash = trashMailboxID && !selected.find(miv => miv.messageitem.Message.MailboxID !== trashMailboxID); + dom._kids(msgElem, dom.div(attr.role('region'), attr.arialabel('Buttons for multiple messages'), style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }), dom.div(style({ padding: '4ex', backgroundColor: 'white', borderRadius: '.25em', border: '1px solid #ccc' }), dom.div(style({ textAlign: 'center', marginBottom: '4ex' }), '' + selected.length + ' messages selected'), dom.div(dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(cmdArchive, shortcuts)), ' ', allTrash ? + dom.clickbutton('Delete', attr.title('Permanently delete messages.'), clickCmd(cmdDelete, shortcuts)) : + dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(cmdTrash, shortcuts)), ' ', dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdJunk, shortcuts)), ' ', dom.clickbutton('Move to...', function click(e) { + movePopover(e, listMailboxes(), selected.map(miv => miv.messageitem.Message)); + }), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels ...'), function click(e) { + labelsPopover(e, selected.map(miv => miv.messageitem.Message), possibleLabels); + }), ' ', dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', dom.clickbutton('Mark read', clickCmd(cmdMarkRead, shortcuts)), ' ', dom.clickbutton('Mark unread', clickCmd(cmdMarkUnread, shortcuts)))))); + } + } + if (activeChanged) { + setLocationHash(); + } + }; + // Moves the currently focused msgitemView, without changing selection. + const moveFocus = (miv) => { + const oldstate = state(); + focus = miv; + updateState(oldstate); + }; + const mlv = { + root: dom.div(), + updateFlags: (mailboxID, uid, mask, flags, keywords) => { + // todo optimize: keep mapping of uid to msgitemView for performance. instead of using Array.find + const miv = msgitemViews.find(miv => miv.messageitem.Message.MailboxID === mailboxID && miv.messageitem.Message.UID === uid); + if (!miv) { + // Happens for messages outside of view. + log('could not find msgitemView for uid', uid); + return; + } + miv.updateFlags(mask, flags, keywords); + if (msgView && msgView.messageitem.Message.ID === miv.messageitem.Message.ID) { + msgView.updateKeywords(keywords); + } + }, + addMessageItems: (messageItems) => { + if (messageItems.length === 0) { + return; + } + messageItems.forEach(mi => { + const miv = newMsgitemView(mi, mlv, otherMailbox(mi.Message.MailboxID)); + const orderNewest = !settings.orderAsc; + const tm = mi.Message.Received.getTime(); + const nextmivindex = msgitemViews.findIndex(miv => { + const vtm = miv.messageitem.Message.Received.getTime(); + return orderNewest && vtm <= tm || !orderNewest && tm <= vtm; + }); + if (nextmivindex < 0) { + mlv.root.appendChild(miv.root); + msgitemViews.push(miv); + } + else { + mlv.root.insertBefore(miv.root, msgitemViews[nextmivindex].root); + msgitemViews.splice(nextmivindex, 0, miv); + } + }); + const oldstate = state(); + if (!focus) { + focus = msgitemViews[0]; + } + if (selected.length === 0) { + selected = [msgitemViews[0]]; + } + updateState(oldstate); + }, + removeUIDs: (mailboxID, uids) => { + const uidmap = {}; + uids.forEach(uid => uidmap['' + mailboxID + ',' + uid] = true); // todo: we would like messageID here. + const key = (miv) => '' + miv.messageitem.Message.MailboxID + ',' + miv.messageitem.Message.UID; + const oldstate = state(); + selected = selected.filter(miv => !uidmap[key(miv)]); + if (focus && uidmap[key(focus)]) { + const index = msgitemViews.indexOf(focus); + var nextmiv; + for (let i = index + 1; i < msgitemViews.length; i++) { + if (!uidmap[key(msgitemViews[i])]) { + nextmiv = msgitemViews[i]; + break; + } + } + if (!nextmiv) { + for (let i = index - 1; i >= 0; i--) { + if (!uidmap[key(msgitemViews[i])]) { + nextmiv = msgitemViews[i]; + break; + } + } + } + if (nextmiv) { + focus = nextmiv; + } + else { + focus = null; + } + } + if (selected.length === 0 && focus) { + selected = [focus]; + } + updateState(oldstate); + let i = 0; + while (i < msgitemViews.length) { + const miv = msgitemViews[i]; + const k = '' + miv.messageitem.Message.MailboxID + ',' + miv.messageitem.Message.UID; + if (!uidmap[k]) { + i++; + continue; + } + miv.remove(); + msgitemViews.splice(i, 1); + } + }, + // For location hash. + activeMessageID: () => selected.length === 1 ? selected[0].messageitem.Message.ID : 0, + redraw: (miv) => { + miv.root.classList.toggle('focus', miv === focus); + miv.root.classList.toggle('active', selected.indexOf(miv) >= 0); + }, + anchorMessageID: () => msgitemViews[msgitemViews.length - 1].messageitem.Message.ID, + addMsgitemViews: (mivs) => { + mlv.root.append(...mivs.map(v => v.root)); + msgitemViews.push(...mivs); + }, + clear: () => { + dom._kids(mlv.root); + msgitemViews.forEach(miv => miv.remove()); + msgitemViews = []; + focus = null; + selected = []; + dom._kids(msgElem); + setLocationHash(); + }, + unselect: () => { + const oldstate = state(); + selected = []; + updateState(oldstate); + }, + select: (miv) => { + const oldstate = state(); + focus = miv; + selected = [miv]; + updateState(oldstate); + }, + selected: () => selected, + openMessage: (miv, initial, parsedMessageOpt) => { + const oldstate = state(); + focus = miv; + selected = [miv]; + updateState(oldstate, initial, parsedMessageOpt); + }, + click: (miv, ctrl, shift) => { + if (msgitemViews.length === 0) { + return; + } + const oldstate = state(); + if (shift) { + const mivindex = msgitemViews.indexOf(miv); + // Set selection from start of most recent range. + let recentindex; + if (selected.length > 0) { + let o = selected.length - 1; + recentindex = msgitemViews.indexOf(selected[o]); + while (o > 0) { + if (selected[o - 1] === msgitemViews[recentindex - 1]) { + recentindex--; + } + else if (selected[o - 1] === msgitemViews[recentindex + 1]) { + recentindex++; + } + else { + break; + } + o--; + } + } + else { + recentindex = mivindex; + } + const oselected = selected; + if (mivindex < recentindex) { + selected = msgitemViews.slice(mivindex, recentindex + 1); + selected.reverse(); + } + else { + selected = msgitemViews.slice(recentindex, mivindex + 1); + } + if (ctrl) { + selected = oselected.filter(e => !selected.includes(e)).concat(selected); + } + } + else if (ctrl) { + const index = selected.indexOf(miv); + if (index < 0) { + selected.push(miv); + } + else { + selected.splice(index, 1); + } + } + else { + selected = [miv]; + } + focus = miv; + updateState(oldstate); + }, + key: async (k, e) => { + if (attachmentView) { + attachmentView.key(k, e); + return; + } + const moveKeys = [ + ' ', 'ArrowUp', 'ArrowDown', + 'PageUp', 'h', 'H', + 'PageDown', 'l', 'L', + 'j', 'J', + 'k', 'K', + 'Home', ',', '<', + 'End', '.', '>', + ]; + if (!e.altKey && moveKeys.includes(e.key)) { + const moveclick = (index, clip) => { + if (clip && index < 0) { + index = 0; + } + else if (clip && index >= msgitemViews.length) { + index = msgitemViews.length - 1; + } + if (index < 0 || index >= msgitemViews.length) { + return; + } + if (e.ctrlKey) { + moveFocus(msgitemViews[index]); + } + else { + mlv.click(msgitemViews[index], false, e.shiftKey); + } + }; + let i = msgitemViews.findIndex(miv => miv === focus); + if (e.key === ' ') { + if (i >= 0) { + mlv.click(msgitemViews[i], e.ctrlKey, e.shiftKey); + } + } + else if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') { + moveclick(i - 1, e.key === 'K'); + } + else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') { + moveclick(i + 1, e.key === 'J'); + } + else if (e.key === 'PageUp' || e.key === 'h' || e.key == 'H' || e.key === 'PageDown' || e.key === 'l' || e.key === 'L') { + if (msgitemViews.length > 0) { + let n = Math.max(1, Math.floor(scrollElemHeight() / mlv.itemHeight()) - 1); + if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H') { + n = -n; + } + moveclick(i + n, true); + } + } + else if (e.key === 'Home' || e.key === ',' || e.key === '<') { + moveclick(0, true); + } + else if (e.key === 'End' || e.key === '.' || e.key === '>') { + moveclick(msgitemViews.length - 1, true); + } + e.preventDefault(); + e.stopPropagation(); + return; + } + const fn = shortcuts[k]; + if (fn) { + e.preventDefault(); + e.stopPropagation(); + fn(); + } + else if (msgView) { + msgView.key(k, e); + } + else { + log('key not handled', k); + } + }, + mailboxes: () => listMailboxes(), + itemHeight: () => msgitemViews.length > 0 ? msgitemViews[0].root.getBoundingClientRect().height : 25, + cmdArchive: cmdArchive, + cmdTrash: cmdTrash, + cmdDelete: cmdDelete, + cmdJunk: cmdJunk, + cmdMarkNotJunk: cmdMarkNotJunk, + cmdMarkRead: cmdMarkRead, + cmdMarkUnread: cmdMarkUnread, + }; + return mlv; +}; +const newMailboxView = (xmb, mailboxlistView) => { + const plusbox = '⊞'; + const minusbox = '⊟'; + const cmdCollapse = async () => { + settings.mailboxCollapsed[mbv.mailbox.ID] = true; + settingsPut(settings); + mailboxlistView.updateHidden(); + mbv.root.focus(); + }; + const cmdExpand = async () => { + delete (settings.mailboxCollapsed[mbv.mailbox.ID]); + settingsPut(settings); + mailboxlistView.updateHidden(); + mbv.root.focus(); + }; + const collapseElem = dom.span(dom._class('mailboxcollapse'), minusbox, function click(e) { + e.stopPropagation(); + cmdCollapse(); + }); + const expandElem = dom.span(plusbox, function click(e) { + e.stopPropagation(); + cmdExpand(); + }); + let name, unread; + let actionBtn; + const cmdOpenActions = async () => { + const trashmb = mailboxlistView.mailboxes().find(mb => mb.Trash); + const remove = popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Move to trash', attr.title('Move mailbox, its messages and its mailboxes to the trash.'), async function click() { + if (!trashmb) { + window.alert('No mailbox configured for trash yet.'); + return; + } + if (!window.confirm('Are you sure you want to move this mailbox, its messages and its mailboxes to the trash?')) { + return; + } + remove(); + await withStatus('Moving mailbox to trash', client.MailboxRename(mbv.mailbox.ID, trashmb.Name + '/' + mbv.mailbox.Name)); + })), dom.div(dom.clickbutton('Delete mailbox', attr.title('Permanently delete this mailbox and all its messages.'), async function click() { + if (!window.confirm('Are you sure you want to permanently delete this mailbox and all its messages?')) { + return; + } + remove(); + await withStatus('Deleting mailbox', client.MailboxDelete(mbv.mailbox.ID)); + })), dom.div(dom.clickbutton('Empty mailbox', async function click() { + if (!window.confirm('Are you sure you want to empty this mailbox, permanently removing its messages? Mailboxes inside this mailbox are not affected.')) { + return; + } + remove(); + await withStatus('Emptying mailbox', client.MailboxEmpty(mbv.mailbox.ID)); + })), dom.div(dom.clickbutton('Rename mailbox', function click() { + remove(); + let fieldset, name; + const remove2 = popover(actionBtn, {}, dom.form(async function submit(e) { + e.preventDefault(); + await withStatus('Renaming mailbox', client.MailboxRename(mbv.mailbox.ID, name.value), fieldset); + remove2(); + }, fieldset = dom.fieldset(dom.label('Name ', name = dom.input(attr.required(''), attr.value(mbv.mailbox.Name), prop({ selectionStart: 0, selectionEnd: mbv.mailbox.Name.length }))), ' ', dom.submitbutton('Rename')))); + })), dom.div(dom.clickbutton('Set role for mailbox...', attr.title('Set a special-use role on the mailbox, making it the designated mailbox for either Archived, Sent, Draft, Trashed or Junk messages.'), async function click() { + remove(); + const setUse = async (set) => { + const mb = { ...mbv.mailbox }; + mb.Archive = mb.Draft = mb.Junk = mb.Sent = mb.Trash = false; + set(mb); + await withStatus('Marking mailbox as special use', client.MailboxSetSpecialUse(mb)); + }; + popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Archive', async function click() { await setUse((mb) => { mb.Archive = true; }); })), dom.div(dom.clickbutton('Draft', async function click() { await setUse((mb) => { mb.Draft = true; }); })), dom.div(dom.clickbutton('Junk', async function click() { await setUse((mb) => { mb.Junk = true; }); })), dom.div(dom.clickbutton('Sent', async function click() { await setUse((mb) => { mb.Sent = true; }); })), dom.div(dom.clickbutton('Trash', async function click() { await setUse((mb) => { mb.Trash = true; }); })))); + })))); + }; + // Keep track of dragenter/dragleave ourselves, we don't get a neat 1 enter and 1 + // leave event from browsers, we get events for multiple of this elements children. + let drags = 0; + const root = dom.div(dom._class('mailboxitem'), attr.tabindex('0'), async function keydown(e) { + if (e.key === 'Enter') { + e.stopPropagation(); + await withStatus('Opening mailbox', mbv.open(true)); + } + else if (e.key === 'ArrowLeft') { + e.stopPropagation(); + if (!mailboxlistView.mailboxLeaf(mbv)) { + cmdCollapse(); + } + } + else if (e.key === 'ArrowRight') { + e.stopPropagation(); + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + cmdExpand(); + } + } + else if (e.key === 'b') { + cmdOpenActions(); + } + }, async function click() { + mbv.root.focus(); + await withStatus('Opening mailbox', mbv.open(true)); + }, function dragover(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, function dragenter(e) { + e.stopPropagation(); + drags++; + mbv.root.classList.toggle('dropping', true); + }, function dragleave(e) { + e.stopPropagation(); + drags--; + if (drags <= 0) { + mbv.root.classList.toggle('dropping', false); + } + }, async function drop(e) { + e.preventDefault(); + mbv.root.classList.toggle('dropping', false); + const msgIDs = JSON.parse(e.dataTransfer.getData('application/vnd.mox.messages')); + await withStatus('Moving to ' + xmb.Name, client.MessageMove(msgIDs, xmb.ID)); + }, dom.div(dom._class('mailbox'), style({ display: 'flex', justifyContent: 'space-between' }), name = dom.div(style({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' })), dom.div(style({ whiteSpace: 'nowrap' }), actionBtn = dom.clickbutton(dom._class('mailboxhoveronly'), '...', attr.tabindex('-1'), // Without, tab breaks because this disappears when mailbox loses focus. + attr.arialabel('Mailbox actions'), attr.title('Actions on mailbox, like deleting, emptying, renaming.'), function click(e) { + e.stopPropagation(); + cmdOpenActions(); + }), ' ', unread = dom.b(dom._class('silenttitle'))))); + const update = () => { + let moreElems = []; + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + moreElems = [' ', expandElem]; + } + else if (!mailboxlistView.mailboxLeaf(mbv)) { + moreElems = [' ', collapseElem]; + } + let ntotal = mbv.mailbox.Total; + let nunread = mbv.mailbox.Unread; + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + const prefix = mbv.mailbox.Name + '/'; + for (const mb of mailboxlistView.mailboxes()) { + if (mb.Name.startsWith(prefix)) { + ntotal += mb.Total; + nunread += mb.Unread; + } + } + } + dom._kids(name, dom.span(mbv.parents > 0 ? style({ paddingLeft: '' + (mbv.parents * 2 / 3) + 'em' }) : [], mbv.shortname, attr.title('Total messages: ' + ntotal), moreElems)); + dom._kids(unread, nunread === 0 ? ['', attr.title('')] : ['' + nunread, attr.title('' + nunread + ' unread')]); + }; + const mbv = { + root: root, + // Set by update(), typically through MailboxlistView updateMailboxNames after inserting. + shortname: '', + parents: 0, + hidden: false, + update: update, + mailbox: xmb, + open: async (load) => { + await mailboxlistView.openMailboxView(mbv, load, false); + }, + setCounts: (total, unread) => { + mbv.mailbox.Total = total; + mbv.mailbox.Unread = unread; + // If mailbox is collapsed, parent needs updating. + // todo optimize: only update parents, not all. + mailboxlistView.updateCounts(); + }, + setSpecialUse: (specialUse) => { + mbv.mailbox.Archive = specialUse.Archive; + mbv.mailbox.Draft = specialUse.Draft; + mbv.mailbox.Junk = specialUse.Junk; + mbv.mailbox.Sent = specialUse.Sent; + mbv.mailbox.Trash = specialUse.Trash; + }, + setKeywords: (keywords) => { + mbv.mailbox.Keywords = keywords; + }, + }; + return mbv; +}; +const newMailboxlistView = (msglistView, requestNewView, updatePageTitle, setLocationHash, unloadSearch) => { + let mailboxViews = []; + let mailboxViewActive; + // Reorder mailboxes and assign new short names and indenting. Called after changing the list. + const updateMailboxNames = () => { + const draftmb = mailboxViews.find(mbv => mbv.mailbox.Draft)?.mailbox; + const sentmb = mailboxViews.find(mbv => mbv.mailbox.Sent)?.mailbox; + const archivemb = mailboxViews.find(mbv => mbv.mailbox.Archive)?.mailbox; + const trashmb = mailboxViews.find(mbv => mbv.mailbox.Trash)?.mailbox; + const junkmb = mailboxViews.find(mbv => mbv.mailbox.Junk)?.mailbox; + const stem = (s) => s.split('/')[0]; + const specialUse = [ + (mb) => stem(mb.Name) === 'Inbox', + (mb) => draftmb && stem(mb.Name) === stem(draftmb.Name), + (mb) => sentmb && stem(mb.Name) === stem(sentmb.Name), + (mb) => archivemb && stem(mb.Name) === stem(archivemb.Name), + (mb) => trashmb && stem(mb.Name) === stem(trashmb.Name), + (mb) => junkmb && stem(mb.Name) === stem(junkmb.Name), + ]; + mailboxViews.sort((mbva, mbvb) => { + const ai = specialUse.findIndex(fn => fn(mbva.mailbox)); + const bi = specialUse.findIndex(fn => fn(mbvb.mailbox)); + if (ai < 0 && bi >= 0) { + return 1; + } + else if (ai >= 0 && bi < 0) { + return -1; + } + else if (ai >= 0 && bi >= 0 && ai !== bi) { + return ai < bi ? -1 : 1; + } + return mbva.mailbox.Name < mbvb.mailbox.Name ? -1 : 1; + }); + let prevmailboxname = ''; + mailboxViews.forEach(mbv => { + const mb = mbv.mailbox; + let shortname = mb.Name; + let parents = 0; + if (prevmailboxname) { + let prefix = ''; + for (const s of prevmailboxname.split('/')) { + const nprefix = prefix + s + '/'; + if (mb.Name.startsWith(nprefix)) { + prefix = nprefix; + parents++; + } + else { + break; + } + } + shortname = mb.Name.substring(prefix.length); + } + mbv.shortname = shortname; + mbv.parents = parents; + mbv.update(); // Render name. + prevmailboxname = mb.Name; + }); + updateHidden(); + }; + const mailboxHidden = (mb, mailboxesMap) => { + let s = ''; + for (const e of mb.Name.split('/')) { + if (s) { + s += '/'; + } + s += e; + const pmb = mailboxesMap[s]; + if (pmb && settings.mailboxCollapsed[pmb.ID] && s !== mb.Name) { + return true; + } + } + return false; + }; + const mailboxLeaf = (mbv) => { + const index = mailboxViews.findIndex(v => v === mbv); + const prefix = mbv.mailbox.Name + '/'; + const r = index < 0 || index + 1 >= mailboxViews.length || !mailboxViews[index + 1].mailbox.Name.startsWith(prefix); + return r; + }; + const updateHidden = () => { + const mailboxNameMap = {}; + mailboxViews.forEach((mbv) => mailboxNameMap[mbv.mailbox.Name] = mbv.mailbox); + for (const mbv of mailboxViews) { + mbv.hidden = mailboxHidden(mbv.mailbox, mailboxNameMap); + } + mailboxViews.forEach(mbv => mbv.update()); + dom._kids(mailboxesElem, mailboxViews.filter(mbv => !mbv.hidden)); + }; + const root = dom.div(); + const mailboxesElem = dom.div(); + dom._kids(root, dom.div(attr.role('region'), attr.arialabel('Mailboxes'), dom.div(dom.h1('Mailboxes', style({ display: 'inline', fontSize: 'inherit' })), ' ', dom.clickbutton('+', attr.arialabel('Create new mailbox.'), attr.title('Create new mailbox.'), style({ padding: '0 .25em' }), function click(e) { + let fieldset, name; + const remove = popover(e.target, {}, dom.form(async function submit(e) { + e.preventDefault(); + await withStatus('Creating mailbox', client.MailboxCreate(name.value), fieldset); + remove(); + }, fieldset = dom.fieldset(dom.label('Name ', name = dom.input(attr.required('yes'), focusPlaceholder('Lists/Go/Nuts'))), ' ', dom.submitbutton('Create')))); + })), mailboxesElem)); + const loadMailboxes = (mailboxes, mbnameOpt) => { + mailboxViews = mailboxes.map(mb => newMailboxView(mb, mblv)); + updateMailboxNames(); + if (mbnameOpt) { + const mbv = mailboxViews.find(mbv => mbv.mailbox.Name === mbnameOpt); + if (mbv) { + openMailboxView(mbv, false, false); + } + } + }; + const closeMailbox = () => { + if (!mailboxViewActive) { + return; + } + mailboxViewActive.root.classList.toggle('active', false); + mailboxViewActive = null; + updatePageTitle(); + }; + const openMailboxView = async (mbv, load, focus) => { + // Ensure searchbarElem is in inactive state. + unloadSearch(); + if (mailboxViewActive) { + mailboxViewActive.root.classList.toggle('active', false); + } + mailboxViewActive = mbv; + mbv.root.classList.toggle('active', true); + updatePageTitle(); + if (load) { + setLocationHash(); + const f = newFilter(); + f.MailboxID = mbv.mailbox.ID; + await withStatus('Requesting messages', requestNewView(true, f, newNotFilter())); + } + else { + msglistView.clear(); + setLocationHash(); + } + if (focus) { + mbv.root.focus(); + } + }; + const mblv = { + root: root, + loadMailboxes: loadMailboxes, + closeMailbox: closeMailbox, + openMailboxView: openMailboxView, + mailboxLeaf: mailboxLeaf, + updateHidden: updateHidden, + updateCounts: () => mailboxViews.forEach(mbv => mbv.update()), + activeMailbox: () => mailboxViewActive ? mailboxViewActive.mailbox : null, + mailboxes: () => mailboxViews.map(mbv => mbv.mailbox), + findMailboxByID: (id) => mailboxViews.find(mbv => mbv.mailbox.ID === id)?.mailbox || null, + findMailboxByName: (name) => mailboxViews.find(mbv => mbv.mailbox.Name === name)?.mailbox || null, + openMailboxID: async (id, focus) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === id); + if (mbv) { + await openMailboxView(mbv, false, focus); + } + else { + throw new Error('unknown mailbox'); + } + }, + addMailbox: (mb) => { + const mbv = newMailboxView(mb, mblv); + mailboxViews.push(mbv); + updateMailboxNames(); + }, + renameMailbox: (mailboxID, newName) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID); + if (!mbv) { + throw new Error('rename event: unknown mailbox'); + } + mbv.mailbox.Name = newName; + updateMailboxNames(); + }, + removeMailbox: (mailboxID) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID); + if (!mbv) { + throw new Error('remove event: unknown mailbox'); + } + if (mbv === mailboxViewActive) { + const inboxv = mailboxViews.find(mbv => mbv.mailbox.Name === 'Inbox'); + if (inboxv) { + openMailboxView(inboxv, true, false); // note: async function + } + } + const index = mailboxViews.findIndex(mbv => mbv.mailbox.ID === mailboxID); + mailboxViews.splice(index, 1); + updateMailboxNames(); + }, + setMailboxCounts: (mailboxID, total, unread) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID); + if (!mbv) { + throw new Error('mailbox message/unread count changed: unknown mailbox'); + } + mbv.setCounts(total, unread); + if (mbv === mailboxViewActive) { + updatePageTitle(); + } + }, + setMailboxSpecialUse: (mailboxID, specialUse) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID); + if (!mbv) { + throw new Error('special-use flags changed: unknown mailbox'); + } + mbv.setSpecialUse(specialUse); + updateMailboxNames(); + }, + setMailboxKeywords: (mailboxID, keywords) => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID); + if (!mbv) { + throw new Error('keywords changed: unknown mailbox'); + } + mbv.setKeywords(keywords); + }, + }; + return mblv; +}; +const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewClose) => { + let form; + let words, mailbox, mailboxkids, from, to, oldestDate, oldestTime, newestDate, newestTime, subject, flagViews, labels, minsize, maxsize; + let attachmentNone, attachmentAny, attachmentImage, attachmentPDF, attachmentArchive, attachmentSpreadsheet, attachmentDocument, attachmentPresentation; + const makeDateTime = (dt, tm) => { + if (!dt && !tm) { + return ''; + } + if (!dt) { + const now = new Date(); + const pad0 = (v) => v <= 9 ? '0' + v : '' + v; + dt = [now.getFullYear(), pad0(now.getMonth() + 1), pad0(now.getDate())].join('-'); + } + if (dt && tm) { + return dt + 'T' + tm; + } + return dt; + }; + const packString = (s) => needsDquote(s) ? dquote(s) : s; + const packNotString = (s) => '-' + (needsDquote(s) || s.startsWith('-') ? dquote(s) : s); + // Sync the form fields back into the searchbarElem. We process in order of the form, + // so we may rearrange terms. We also canonicalize quoting and space and remove + // empty strings. + const updateSearchbar = () => { + let tokens = []; + if (mailbox.value && mailbox.value !== '-1') { + const v = mailbox.value === '0' ? '' : mailbox.selectedOptions[0].text; // '0' is "All mailboxes", represented as "mb:". + tokens.push([false, 'mb', false, v]); + } + if (mailboxkids.checked) { + tokens.push([false, 'submb', false, '']); + } + tokens.push(...parseSearchTokens(words.value)); + tokens.push(...parseSearchTokens(from.value).map(t => [t[0], 'f', false, t[3]])); + tokens.push(...parseSearchTokens(to.value).map(t => [t[0], 't', false, t[3]])); + const start = makeDateTime(oldestDate.value, oldestTime.value); + if (start) { + tokens.push([false, 'start', false, start]); + } + const end = makeDateTime(newestDate.value, newestTime.value); + if (end) { + tokens.push([false, 'end', false, end]); + } + tokens.push(...parseSearchTokens(subject.value).map(t => [t[0], 's', false, t[3]])); + const check = (elem, tag, value) => { + if (elem.checked) { + tokens.push([false, tag, false, value]); + } + }; + check(attachmentNone, 'a', 'none'); + check(attachmentAny, 'a', 'any'); + check(attachmentImage, 'a', 'image'); + check(attachmentPDF, 'a', 'pdf'); + check(attachmentArchive, 'a', 'archive'); + check(attachmentSpreadsheet, 'a', 'spreadsheet'); + check(attachmentDocument, 'a', 'document'); + check(attachmentPresentation, 'a', 'presentation'); + tokens.push(...flagViews.filter(fv => fv.active !== null).map(fv => { + return [!fv.active, 'l', false, fv.flag]; + })); + tokens.push(...parseSearchTokens(labels.value).map(t => [t[0], 'l', t[2], t[3]])); + tokens.push(...headerViews.filter(hv => hv.key.value).map(hv => [false, 'h', false, hv.key.value + ':' + hv.value.value])); + const minstr = parseSearchSize(minsize.value)[0]; + if (minstr) { + tokens.push([false, 'minsize', false, minstr]); + } + const maxstr = parseSearchSize(maxsize.value)[0]; + if (maxstr) { + tokens.push([false, 'maxsize', false, maxstr]); + } + searchbarElem.value = tokens.map(packToken).join(' '); + }; + const setDateTime = (s, dateElem, timeElem) => { + if (!s) { + return; + } + const t = s.split('T', 2); + const dt = t.length === 2 || t[0].includes('-') ? t[0] : ''; + const tm = t.length === 2 ? t[1] : (t[0].includes(':') ? t[0] : ''); + if (dt) { + dateElem.value = dt; + } + if (tm) { + timeElem.value = tm; + } + }; + // Update form based on searchbarElem. We parse the searchbarElem into a filter. Then reset + // and populate the form. + const updateForm = () => { + const [f, notf, strs] = parseSearch(searchbarElem.value, mailboxlistView); + form.reset(); + const packTwo = (l, lnot) => (l || []).map(packString).concat((lnot || []).map(packNotString)).join(' '); + if (f.MailboxName) { + const o = [...mailbox.options].find(o => o.text === f.MailboxName) || mailbox.options[0]; + if (o) { + o.selected = true; + } + } + else if (f.MailboxID === -1) { + // "All mailboxes except ...". + mailbox.options[0].selected = true; + } + else { + const id = '' + f.MailboxID; + const o = [...mailbox.options].find(o => o.value === id) || mailbox.options[0]; + o.selected = true; + } + mailboxkids.checked = f.MailboxChildrenIncluded; + words.value = packTwo(f.Words, notf.Words); + from.value = packTwo(f.From, notf.From); + to.value = packTwo(f.To, notf.To); + setDateTime(strs.Oldest, oldestDate, oldestTime); + setDateTime(strs.Newest, newestDate, newestTime); + subject.value = packTwo(f.Subject, notf.Subject); + const elem = { + none: attachmentNone, + any: attachmentAny, + image: attachmentImage, + pdf: attachmentPDF, + archive: attachmentArchive, + spreadsheet: attachmentSpreadsheet, + document: attachmentDocument, + presentation: attachmentPresentation, + }[f.Attachments]; + if (elem) { + attachmentChecks(elem, true); + } + const otherlabels = []; + const othernotlabels = []; + flagViews.forEach(fv => fv.active = null); + const setLabels = (flabels, other, not) => { + (flabels || []).forEach(l => { + l = l.toLowerCase(); + // Find if this is a well-known flag. + const fv = flagViews.find(fv => fv.flag.toLowerCase() === l); + if (fv) { + fv.active = !not; + fv.update(); + } + else { + other.push(l); + } + }); + }; + setLabels(f.Labels, otherlabels, false); + setLabels(notf.Labels, othernotlabels, true); + labels.value = packTwo(otherlabels, othernotlabels); + headerViews.slice(1).forEach(hv => hv.root.remove()); + headerViews = [headerViews[0]]; + if (f.Headers && f.Headers.length > 0) { + (f.Headers || []).forEach((kv, index) => { + const [k, v] = kv || ['', '']; + if (index > 0) { + addHeaderView(); + } + headerViews[index].key.value = k; + headerViews[index].value.value = v; + }); + } + if (strs.SizeMin) { + minsize.value = strs.SizeMin; + } + if (strs.SizeMax) { + maxsize.value = strs.SizeMax; + } + }; + const attachmentChecks = (elem, set) => { + if (elem.checked || set) { + for (const e of [attachmentNone, attachmentAny, attachmentImage, attachmentPDF, attachmentArchive, attachmentSpreadsheet, attachmentDocument, attachmentPresentation]) { + if (e !== elem) { + e.checked = false; + } + else if (set) { + e.checked = true; + } + } + } + }; + const changeHandlers = [ + function change() { + updateSearchbar(); + }, + function keyup() { + updateSearchbar(); + }, + ]; + const attachmentHandlers = [ + function change(e) { + attachmentChecks(e.target); + }, + function mousedown(e) { + // Radiobuttons cannot be deselected normally. With this handler a user can push + // down on the button, then move pointer out of button and release the button to + // clear the radiobutton. + const target = e.target; + if (e.buttons === 1 && target.checked) { + target.checked = false; + e.preventDefault(); + } + }, + ...changeHandlers, + ]; + let headersCell; // Where we add headerViews. + let headerViews; + const newHeaderView = (first) => { + let key, value; + const root = dom.div(style({ display: 'flex' }), key = dom.input(focusPlaceholder('Header name'), style({ width: '40%' }), changeHandlers), dom.div(style({ width: '.5em' })), value = dom.input(focusPlaceholder('Header value'), style({ flexGrow: 1 }), changeHandlers), dom.div(style({ width: '2.5em', paddingLeft: '.25em' }), dom.clickbutton('+', style({ padding: '0 .25em' }), attr.arialabel('Add row for another header filter.'), attr.title('Add row for another header filter.'), function click() { + addHeaderView(); + }), ' ', first ? [] : dom.clickbutton('-', style({ padding: '0 .25em' }), attr.arialabel('Remove row.'), attr.title('Remove row.'), function click() { + root.remove(); + const index = headerViews.findIndex(v => v === hv); + headerViews.splice(index, 1); + updateSearchbar(); + }))); + const hv = { root: root, key: key, value: value }; + return hv; + }; + const addHeaderView = () => { + const hv = newHeaderView(false); + headersCell.appendChild(hv.root); + headerViews.push(hv); + }; + const setPeriod = (d) => { + newestDate.value = ''; + newestTime.value = ''; + const pad0 = (v) => v <= 9 ? '0' + v : '' + v; + const dt = [d.getFullYear(), pad0(d.getMonth() + 1), pad0(d.getDate())].join('-'); + const tm = '' + pad0(d.getHours()) + ':' + pad0(d.getMinutes()); + oldestDate.value = dt; + oldestTime.value = tm; + updateSearchbar(); + }; + const root = dom.div(style({ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', zIndex: zindexes.compose }), function click(e) { + e.stopPropagation(); + searchViewClose(); + }, function keyup(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + searchViewClose(); + } + }, dom.search(style({ position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: 'white', boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', borderRadius: '.15em' }), function click(e) { + e.stopPropagation(); + }, + // This is a separate form, inside the form with the overall search field because + // when updating the form based on the parsed searchbar, we first need to reset it. + form = dom.form(dom.table(dom._class('search'), style({ width: '100%' }), dom.tr(dom.td(dom.label('Mailbox', attr.for('searchMailbox')), attr.title('Filter by mailbox, including children of the mailbox.')), dom.td(mailbox = dom.select(attr.id('searchMailbox'), style({ width: '100%' }), dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), dom.option('All mailboxes', attr.value('0')), changeHandlers), dom.div(style({ paddingTop: '.5ex' }), dom.label(mailboxkids = dom.input(attr.type('checkbox'), changeHandlers), ' Also search in mailboxes below the selected mailbox.')))), dom.tr(dom.td(dom.label('Text', attr.for('searchWords'))), dom.td(words = dom.input(attr.id('searchWords'), attr.title('Filter by text, case-insensitive, substring match, not necessarily whole words.'), focusPlaceholder('word "exact match" -notword'), style({ width: '100%' }), changeHandlers))), dom.tr(dom.td(dom.label('From', attr.for('searchFrom'))), dom.td(from = dom.input(attr.id('searchFrom'), style({ width: '100%' }), focusPlaceholder('Address or name'), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('To', attr.for('searchTo')), attr.title('Search on addressee, including Cc and Bcc headers.')), dom.td(to = dom.input(attr.id('searchTo'), focusPlaceholder('Address or name, also matches Cc and Bcc addresses'), style({ width: '100%' }), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('Search', attr.for('searchSubject'))), dom.td(subject = dom.input(attr.id('searchSubject'), style({ width: '100%' }), focusPlaceholder('"exact match"'), changeHandlers))), dom.tr(dom.td('Received between', style({ whiteSpace: 'nowrap' })), dom.td(style({ lineHeight: 2 }), dom.div(oldestDate = dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), oldestTime = dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), ' ', dom.clickbutton('x', style({ padding: '0 .3em' }), attr.arialabel('Clear start date.'), attr.title('Clear start date.'), function click() { + oldestDate.value = ''; + oldestTime.value = ''; + updateSearchbar(); + }), ' and ', newestDate = dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), newestTime = dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), ' ', dom.clickbutton('x', style({ padding: '0 .3em' }), attr.arialabel('Clear end date.'), attr.title('Clear end date.'), function click() { + newestDate.value = ''; + newestTime.value = ''; + updateSearchbar(); + })), dom.div(dom.clickbutton('1 day', function click() { + setPeriod(new Date(new Date().getTime() - 24 * 3600 * 1000)); + }), ' ', dom.clickbutton('1 week', function click() { + setPeriod(new Date(new Date().getTime() - 7 * 24 * 3600 * 1000)); + }), ' ', dom.clickbutton('1 month', function click() { + setPeriod(new Date(new Date().getTime() - 31 * 24 * 3600 * 1000)); + }), ' ', dom.clickbutton('1 year', function click() { + setPeriod(new Date(new Date().getTime() - 365 * 24 * 3600 * 1000)); + })))), dom.tr(dom.td('Attachments'), dom.td(dom.label(style({ whiteSpace: 'nowrap' }), attachmentNone = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('none'), attachmentHandlers), ' None'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentAny = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('any'), attachmentHandlers), ' Any'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentImage = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('image'), attachmentHandlers), ' Images'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentPDF = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('pdf'), attachmentHandlers), ' PDFs'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentArchive = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('archive'), attachmentHandlers), ' Archives'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentSpreadsheet = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('spreadsheet'), attachmentHandlers), ' Spreadsheets'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentDocument = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('document'), attachmentHandlers), ' Documents'), ' ', dom.label(style({ whiteSpace: 'nowrap' }), attachmentPresentation = dom.input(attr.type('radio'), attr.name('attachments'), attr.value('presentation'), attachmentHandlers), ' Presentations'), ' ')), dom.tr(dom.td('Labels'), dom.td(style({ lineHeight: 2 }), join(flagViews = Object.entries({ Read: '\\Seen', Replied: '\\Answered', Flagged: '\\Flagged', Deleted: '\\Deleted', Draft: '\\Draft', Forwarded: '$Forwarded', Junk: '$Junk', NotJunk: '$NotJunk', Phishing: '$Phishing', MDNSent: '$MDNSent' }).map(t => { + const [name, flag] = t; + const v = { + active: null, + flag: flag, + root: dom.clickbutton(name, function click() { + if (v.active === null) { + v.active = true; + } + else if (v.active === true) { + v.active = false; + } + else { + v.active = null; + } + v.update(); + updateSearchbar(); + }), + update: () => { + v.root.style.backgroundColor = v.active === true ? '#c4ffa9' : (v.active === false ? '#ffb192' : ''); + }, + }; + return v; + }), () => ' '), ' ', labels = dom.input(focusPlaceholder('todo -done "-dashingname"'), attr.title('User-defined labels.'), changeHandlers))), dom.tr(dom.td('Headers'), headersCell = dom.td(headerViews = [newHeaderView(true)])), dom.tr(dom.td('Size between'), dom.td(minsize = dom.input(style({ width: '6em' }), focusPlaceholder('10kb'), changeHandlers), ' and ', maxsize = dom.input(style({ width: '6em' }), focusPlaceholder('1mb'), changeHandlers)))), dom.div(style({ padding: '1ex', textAlign: 'right' }), dom.submitbutton('Search')), async function submit(e) { + e.preventDefault(); + await searchView.submit(); + }))); + const submit = async () => { + const [f, notf, _] = parseSearch(searchbarElem.value, mailboxlistView); + await startSearch(f, notf); + }; + let loaded = false; + const searchView = { + root: root, + submit: submit, + ensureLoaded: () => { + if (loaded || mailboxlistView.mailboxes().length === 0) { + return; + } + loaded = true; + dom._kids(mailbox, dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), dom.option('All mailboxes', attr.value('0')), mailboxlistView.mailboxes().map(mb => dom.option(mb.Name, attr.value('' + mb.ID)))); + searchView.updateForm(); + }, + updateForm: updateForm, + }; + return searchView; +}; +const init = async () => { + let connectionElem; // SSE connection status/error. Empty when connected. + let layoutElem; // Select dropdown for layout. + let msglistscrollElem; + let queryactivityElem; // We show ... when a query is active and data is forthcoming. + // Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate. + const listendElem = dom.div(style({ borderTop: '1px solid #ccc', color: '#666', margin: '1ex' })); + const listloadingElem = dom.div(style({ textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8' }), 'loading...'); + const listerrElem = dom.div(style({ textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8' })); + let sseID = 0; // Sent by server in initial SSE response. We use it in API calls to make the SSE endpoint return new data we need. + let viewSequence = 0; // Counter for assigning viewID. + let viewID = 0; // Updated when a new view is started, e.g. when opening another mailbox or starting a search. + let search = { + active: false, + query: '', // The query, as shown in the searchbar. Used in location hash. + }; + let requestSequence = 0; // Counter for assigning requestID. + let requestID = 0; // Current request, server will mirror it in SSE data. If we get data for a different id, we ignore it. + let requestViewEnd = false; // If true, there is no more data to fetch, no more page needed for this view. + let requestFilter = newFilter(); + let requestNotFilter = newNotFilter(); + let requestMsgID = 0; // If > 0, we are still expecting a parsed message for the view, coming from the query. Either we get it and set msgitemViewActive and clear this, or we get to the end of the data and clear it. + const updatePageTitle = () => { + const mb = mailboxlistView && mailboxlistView.activeMailbox(); + const addr = loginAddress ? loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII) : ''; + if (!mb) { + document.title = [addr, 'Mox Webmail'].join(' - '); + } + else { + document.title = ['(' + mb.Unread + ') ' + mb.Name, addr, 'Mox Webmail'].join(' - '); + } + }; + const setLocationHash = () => { + const msgid = requestMsgID || msglistView.activeMessageID(); + const msgidstr = msgid ? ',' + msgid : ''; + let hash; + const mb = mailboxlistView && mailboxlistView.activeMailbox(); + if (mb) { + hash = '#' + mb.Name + msgidstr; + } + else if (search.active) { + hash = '#search ' + search.query + msgidstr; + } + else { + hash = '#'; + } + // We need to set the full URL or we would get errors about insecure operations for + // plain http with firefox. + const l = window.location; + const url = l.protocol + '//' + l.host + l.pathname + l.search + hash; + window.history.replaceState(undefined, '', url); + }; + const loadSearch = (q) => { + search = { active: true, query: q }; + searchbarElem.value = q; + searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)'; // Cleared when another view is loaded. + searchbarElemBox.style.flexGrow = '4'; + }; + const unloadSearch = () => { + searchbarElem.value = ''; + searchbarElem.style.background = ''; + searchbarElem.style.zIndex = ''; + searchbarElemBox.style.flexGrow = ''; // Make search bar smaller again. + search = { active: false, query: '' }; + searchView.root.remove(); + }; + const clearList = () => { + msglistView.clear(); + listendElem.remove(); + listloadingElem.remove(); + listerrElem.remove(); + }; + const requestNewView = async (clearMsgID, filterOpt, notFilterOpt) => { + if (!sseID) { + throw new Error('not connected'); + } + if (clearMsgID) { + requestMsgID = 0; + } + msglistView.root.classList.toggle('loading', true); + clearList(); + viewSequence++; + viewID = viewSequence; + if (filterOpt) { + requestFilter = filterOpt; + requestNotFilter = notFilterOpt || newNotFilter(); + } + requestViewEnd = false; + const bounds = msglistscrollElem.getBoundingClientRect(); + await requestMessages(bounds, 0, requestMsgID); + }; + const requestMessages = async (scrollBounds, anchorMessageID, destMessageID) => { + const fetchCount = Math.max(50, 3 * Math.ceil(scrollBounds.height / msglistView.itemHeight())); + const page = { + AnchorMessageID: anchorMessageID, + Count: fetchCount, + DestMessageID: destMessageID, + }; + requestSequence++; + requestID = requestSequence; + const [f, notf] = refineFilters(requestFilter, requestNotFilter); + const query = { + OrderAsc: settings.orderAsc, + Filter: f, + NotFilter: notf, + }; + const request = { + ID: requestID, + SSEID: sseID, + ViewID: viewID, + Cancel: false, + Query: query, + Page: page, + }; + dom._kids(queryactivityElem, 'loading...'); + msglistscrollElem.appendChild(listloadingElem); + await client.Request(request); + }; + // msgElem can show a message, show actions on multiple messages, or be empty. + let msgElem = dom.div(style({ position: 'absolute', right: 0, left: 0, top: 0, bottom: 0 }), style({ backgroundColor: '#f8f8f8' })); + // Returns possible labels based, either from active mailbox (possibly from search), or all mailboxes. + const possibleLabels = () => { + if (requestFilter.MailboxID > 0) { + const mb = mailboxlistView.findMailboxByID(requestFilter.MailboxID); + if (mb) { + return mb.Keywords || []; + } + } + const all = {}; + mailboxlistView.mailboxes().forEach(mb => { + for (const k of (mb.Keywords || [])) { + all[k] = undefined; + } + }); + const l = Object.keys(all); + l.sort(); + return l; + }; + const refineKeyword = async (kw) => { + settingsPut({ ...settings, refine: 'label:' + kw }); + refineToggleActive(refineLabelBtn); + dom._kids(refineLabelBtn, 'Label: ' + kw); + await withStatus('Requesting messages', requestNewView(false)); + }; + const otherMailbox = (mailboxID) => requestFilter.MailboxID !== mailboxID ? (mailboxlistView.findMailboxByID(mailboxID) || null) : null; + const listMailboxes = () => mailboxlistView.mailboxes(); + const msglistView = newMsglistView(msgElem, listMailboxes, setLocationHash, otherMailbox, possibleLabels, () => msglistscrollElem ? msglistscrollElem.getBoundingClientRect().height : 0, refineKeyword); + const mailboxlistView = newMailboxlistView(msglistView, requestNewView, updatePageTitle, setLocationHash, unloadSearch); + let refineUnreadBtn, refineReadBtn, refineAttachmentsBtn, refineLabelBtn; + const refineToggleActive = (btn) => { + for (const e of [refineUnreadBtn, refineReadBtn, refineAttachmentsBtn, refineLabelBtn]) { + e.classList.toggle('active', e === btn); + } + if (btn !== null && btn !== refineLabelBtn) { + dom._kids(refineLabelBtn, 'Label'); + } + }; + let msglistElem = dom.div(dom._class('msglist'), style({ position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column' }), dom.div(attr.role('region'), attr.arialabel('Filter and sorting buttons for message list'), style({ display: 'flex', justifyContent: 'space-between', backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', padding: '.25em .5em' }), dom.div(dom.h1('Refine:', style({ fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0 }), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), ' ', dom.span(dom._class('btngroup'), refineUnreadBtn = dom.clickbutton(settings.refine === 'unread' ? dom._class('active') : [], 'Unread', attr.title('Only show messages marked as unread.'), async function click(e) { + settingsPut({ ...settings, refine: 'unread' }); + refineToggleActive(e.target); + await withStatus('Requesting messages', requestNewView(false)); + }), refineReadBtn = dom.clickbutton(settings.refine === 'read' ? dom._class('active') : [], 'Read', attr.title('Only show messages marked as read.'), async function click(e) { + settingsPut({ ...settings, refine: 'read' }); + refineToggleActive(e.target); + await withStatus('Requesting messages', requestNewView(false)); + }), refineAttachmentsBtn = dom.clickbutton(settings.refine === 'attachments' ? dom._class('active') : [], 'Attachments', attr.title('Only show messages with attachments.'), async function click(e) { + settingsPut({ ...settings, refine: 'attachments' }); + refineToggleActive(e.target); + await withStatus('Requesting messages', requestNewView(false)); + }), refineLabelBtn = dom.clickbutton(settings.refine.startsWith('label:') ? [dom._class('active'), 'Label: ' + settings.refine.substring('label:'.length)] : 'Label', attr.title('Only show messages with the selected label.'), async function click(e) { + const labels = possibleLabels(); + const remove = popover(e.target, {}, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '1ex' }), labels.map(l => { + const selectLabel = async () => { + settingsPut({ ...settings, refine: 'label:' + l }); + refineToggleActive(e.target); + dom._kids(refineLabelBtn, 'Label: ' + l); + await withStatus('Requesting messages', requestNewView(false)); + remove(); + }; + return dom.div(dom.clickbutton(dom._class('keyword'), l, async function click() { + await selectLabel(); + })); + }), labels.length === 0 ? dom.div('No labels yet, set one on a message first.') : [])); + })), ' ', dom.clickbutton('x', style({ padding: '0 .25em' }), attr.arialabel('Clear refinement filters'), attr.title('Clear refinement filters.'), async function click(e) { + settingsPut({ ...settings, refine: '' }); + refineToggleActive(e.target); + await withStatus('Requesting messages', requestNewView(false)); + })), dom.div(queryactivityElem = dom.span(), ' ', dom.clickbutton('↑↓', attr.title('Toggle sorting by date received.'), settings.orderAsc ? dom._class('invert') : [], async function click(e) { + settingsPut({ ...settings, orderAsc: !settings.orderAsc }); + e.target.classList.toggle('invert', settings.orderAsc); + // We don't want to include the currently selected message because it could cause a + // huge amount of messages to be fetched. e.g. when first message in large mailbox + // was selected, it would now be the last message. + await withStatus('Requesting messages', requestNewView(true)); + }))), dom.div(style({ height: '1ex', position: 'relative' }), dom.div(dom._class('msgitemflags')), dom.div(dom._class('msgitemflagsoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect(); + const width = Math.round(e.clientX - bounds.x); + settingsPut({ ...settings, msglistflagsWidth: width }); + updateMsglistWidths(); + }); + }), dom.div(dom._class('msgitemfrom')), dom.div(dom._class('msgitemfromoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect(); + const x = Math.round(e.clientX - bounds.x - lastflagswidth); + const width = bounds.width - lastflagswidth - lastagewidth; + const pct = 100 * x / width; + settingsPut({ ...settings, msglistfromPct: pct }); + updateMsglistWidths(); + }); + }), dom.div(dom._class('msgitemsubject')), dom.div(dom._class('msgitemsubjectoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect(); + const width = Math.round(bounds.x + bounds.width - e.clientX); + settingsPut({ ...settings, msglistageWidth: width }); + updateMsglistWidths(); + }); + }), dom.div(dom._class('msgitemage'))), dom.div(style({ flexGrow: '1', position: 'relative' }), msglistscrollElem = dom.div(dom._class('yscroll'), attr.role('region'), attr.arialabel('Message list'), async function scroll() { + if (!sseID || requestViewEnd || requestID) { + return; + } + // We know how many entries we have, and how many screenfulls. So we know when we + // only have 2 screen fulls left. That's when we request the next data. + const bounds = msglistscrollElem.getBoundingClientRect(); + if (msglistscrollElem.scrollTop < msglistscrollElem.scrollHeight - 3 * bounds.height) { + return; + } + // log('new request for scroll') + const reqAnchor = msglistView.anchorMessageID(); + await withStatus('Requesting more messages', requestMessages(bounds, reqAnchor, 0)); + }, dom.div(style({ width: '100%', borderSpacing: '0' }), msglistView)))); + let searchbarElem; // Input field for search + // Called by searchView when user executes the search. + const startSearch = async (f, notf) => { + if (!sseID) { + window.alert('Error: not connect'); + return; + } + // If search has an attachment filter, clear it from the quick filter or we will + // confuse the user with no matches. The refinement would override the selection. + if (f.Attachments !== '' && settings.refine === 'attachments') { + settingsPut({ ...settings, refine: '' }); + refineToggleActive(null); + } + search = { active: true, query: searchbarElem.value }; + mailboxlistView.closeMailbox(); + setLocationHash(); + searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)'; // Cleared when another view is loaded. + searchView.root.remove(); + searchbarElem.blur(); + document.body.focus(); + await withStatus('Requesting messages', requestNewView(true, f, notf)); + }; + // Called by searchView when it is closed, due to escape key or click on background. + const searchViewClose = () => { + if (!search.active) { + unloadSearch(); + } + else { + searchbarElem.value = search.query; + searchView.root.remove(); + } + }; + // For dragging. + let mailboxesElem, topcomposeboxElem, mailboxessplitElem; + let splitElem; + let searchbarElemBox; // Detailed search form, opened when searchbarElem gets focused. + const searchbarInitial = () => { + const mailboxActive = mailboxlistView.activeMailbox(); + if (mailboxActive && mailboxActive.Name !== 'Inbox') { + return packToken([false, 'mb', false, mailboxActive.Name]) + ' '; + } + return ''; + }; + const ensureSearchView = () => { + if (searchView.root.parentElement) { + // Already open. + return; + } + searchView.ensureLoaded(); + const pos = searchbarElem.getBoundingClientRect(); + const child = searchView.root.firstChild; + child.style.left = '' + pos.x + 'px'; + child.style.top = '' + (pos.y + pos.height + 2) + 'px'; + // Append to just after search input so next tabindex is at form. + searchbarElem.parentElement.appendChild(searchView.root); + // Make search bar as wide as possible. Made smaller when searchView is hidden again. + searchbarElemBox.style.flexGrow = '4'; + searchbarElem.style.zIndex = zindexes.searchbar; + }; + const cmdSearch = async () => { + searchbarElem.focus(); + if (!searchbarElem.value) { + searchbarElem.value = searchbarInitial(); + } + ensureSearchView(); + searchView.updateForm(); + }; + const cmdCompose = async () => { compose({}); }; + const cmdOpenInbox = async () => { + const mb = mailboxlistView.findMailboxByName('Inbox'); + if (mb) { + await mailboxlistView.openMailboxID(mb.ID, true); + const f = newFilter(); + f.MailboxID = mb.ID; + await withStatus('Requesting messages', requestNewView(true, f, newNotFilter())); + } + }; + const cmdFocusMsg = async () => { + const btn = msgElem.querySelector('button'); + if (btn && btn instanceof HTMLElement) { + btn.focus(); + } + }; + const shortcuts = { + i: cmdOpenInbox, + '/': cmdSearch, + '?': cmdHelp, + 'ctrl ?': cmdTooltip, + c: cmdCompose, + M: cmdFocusMsg, + }; + const webmailroot = dom.div(style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh' }), dom.div(dom._class('topbar'), style({ display: 'flex' }), attr.role('region'), attr.arialabel('Top bar'), topcomposeboxElem = dom.div(dom._class('pad'), style({ width: settings.mailboxesWidth + 'px', textAlign: 'center' }), dom.clickbutton('Compose', attr.title('Compose new email message.'), function click() { + shortcutCmd(cmdCompose, shortcuts); + })), dom.div(dom._class('pad'), style({ paddingLeft: 0, display: 'flex', flexGrow: 1 }), searchbarElemBox = dom.search(style({ display: 'flex', marginRight: '.5em' }), dom.form(style({ display: 'flex', flexGrow: 1 }), searchbarElem = dom.input(attr.placeholder('Search...'), style({ position: 'relative', width: '100%' }), attr.title('Search messages based on criteria like matching free-form text, in a mailbox, labels, addressees.'), focusPlaceholder('word "with space" -notword mb:Inbox f:from@x.example t:rcpt@x.example start:2023-7-1 end:2023-7-8 s:"subject" a:images l:$Forwarded h:Reply-To:other@x.example minsize:500kb'), function click() { + cmdSearch(); + showShortcut('/'); + }, function focus() { + // Make search bar as wide as possible. Made smaller when searchView is hidden again. + searchbarElemBox.style.flexGrow = '4'; + if (!searchbarElem.value) { + searchbarElem.value = searchbarInitial(); + } + }, function blur() { + if (searchbarElem.value === searchbarInitial()) { + searchbarElem.value = ''; + } + if (!search.active) { + searchbarElemBox.style.flexGrow = ''; + } + }, function change() { + searchView.updateForm(); + }, function keyup(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + searchViewClose(); + return; + } + if (searchbarElem.value && searchbarElem.value !== searchbarInitial()) { + ensureSearchView(); + } + searchView.updateForm(); + }), dom.clickbutton('x', attr.arialabel('Cancel and clear search.'), attr.title('Cancel and clear search.'), style({ marginLeft: '.25em', padding: '0 .3em' }), async function click() { + searchbarElem.value = ''; + if (!search.active) { + return; + } + clearList(); + unloadSearch(); + updatePageTitle(); + setLocationHash(); + if (requestID) { + requestSequence++; + requestID = requestSequence; + const query = { + OrderAsc: settings.orderAsc, + Filter: newFilter(), + NotFilter: newNotFilter() + }; + const page = { AnchorMessageID: 0, Count: 0, DestMessageID: 0 }; + const request = { + ID: requestID, + SSEID: sseID, + ViewID: viewID, + Cancel: true, + Query: query, + Page: page, + }; + dom._kids(queryactivityElem); + await withStatus('Canceling query', client.Request(request)); + } + else { + dom._kids(queryactivityElem); + } + }), async function submit(e) { + e.preventDefault(); + await searchView.submit(); + })), connectionElem = dom.div(), statusElem = dom.div(style({ marginLeft: '.5em', flexGrow: '1' }), attr.role('status')), dom.div(style({ paddingLeft: '1em' }), layoutElem = dom.select(attr.title('Layout of message list and message panes. Top/bottom has message list above message view. Left/Right has message list left, message view right. Auto selects based on window width and automatically switches on resize. Wide screens get left/right, smaller screens get top/bottom.'), dom.option('Auto layout', attr.value('auto'), settings.layout === 'auto' ? attr.selected('') : []), dom.option('Top/bottom', attr.value('topbottom'), settings.layout === 'topbottom' ? attr.selected('') : []), dom.option('Left/right', attr.value('leftright'), settings.layout === 'leftright' ? attr.selected('') : []), function change() { + settingsPut({ ...settings, layout: layoutElem.value }); + if (layoutElem.value === 'auto') { + autoselectLayout(); + } + else { + selectLayout(layoutElem.value); + } + }), ' ', dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), ' ', dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), ' ', link('https://github.com/mjl-/mox', 'mox')))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) { + startDrag(e, (e) => { + mailboxesElem.style.width = Math.round(e.clientX) + 'px'; + topcomposeboxElem.style.width = Math.round(e.clientX) + 'px'; + mailboxessplitElem.style.left = 'calc(' + e.clientX + 'px - 2px)'; + splitElem.style.left = 'calc(' + e.clientX + 'px + 1px)'; + settingsPut({ ...settings, mailboxesWidth: Math.round(e.clientX) }); + }); + }), splitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid #bbb' })))); + // searchView is shown when search gets focus. + const searchView = newSearchView(searchbarElem, mailboxlistView, startSearch, searchViewClose); + document.body.addEventListener('keydown', async (e) => { + // Don't do anything for just the press of the modifiers. + switch (e.key) { + case 'OS': + case 'Control': + case 'Shift': + case 'Alt': + return; + } + // Popup have their own handlers, e.g. for scrolling. + if (popupOpen) { + return; + } + // Prevent many regular key presses from being processed, some possibly unintended. + if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) { + // log('skipping key without modifiers on input/textarea') + return; + } + let l = []; + if (e.ctrlKey) { + l.push('ctrl'); + } + if (e.altKey) { + l.push('alt'); + } + if (e.metaKey) { + l.push('meta'); + } + l.push(e.key); + const k = l.join(' '); + if (composeView) { + await composeView.key(k, e); + return; + } + const cmdfn = shortcuts[k]; + if (cmdfn) { + e.preventDefault(); + e.stopPropagation(); + await cmdfn(); + return; + } + msglistView.key(k, e); + }); + let currentLayout = ''; + const selectLayout = (want) => { + if (want === currentLayout) { + return; + } + if (want === 'leftright') { + let left, split, right; + dom._kids(splitElem, left = dom.div(style({ position: 'absolute', left: 0, width: 'calc(' + settings.leftWidthPct + '% - 1px)', top: 0, bottom: 0 }), msglistElem), split = dom.div(style({ position: 'absolute', left: 'calc(' + settings.leftWidthPct + '% - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', backgroundColor: '#aaa', top: 0, bottom: 0, width: '1px', left: '2px', right: '2px' })), function mousedown(e) { + startDrag(e, (e) => { + const bounds = left.getBoundingClientRect(); + const x = Math.round(e.clientX - bounds.x); + left.style.width = 'calc(' + x + 'px - 1px)'; + split.style.left = 'calc(' + x + 'px - 2px)'; + right.style.left = 'calc(' + x + 'px + 1px)'; + settingsPut({ ...settings, leftWidthPct: Math.round(100 * bounds.width / splitElem.getBoundingClientRect().width) }); + updateMsglistWidths(); + }); + }), right = dom.div(style({ position: 'absolute', right: 0, left: 'calc(' + settings.leftWidthPct + '% + 1px)', top: 0, bottom: 0 }), msgElem)); + } + else { + let top, split, bottom; + dom._kids(splitElem, top = dom.div(style({ position: 'absolute', top: 0, height: 'calc(' + settings.topHeightPct + '% - 1px)', left: 0, right: 0 }), msglistElem), split = dom.div(style({ position: 'absolute', top: 'calc(' + settings.topHeightPct + '% - 2px)', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', backgroundColor: '#aaa', left: 0, right: 0, height: '1px', top: '2px', bottom: '2px' })), function mousedown(e) { + startDrag(e, (e) => { + const bounds = top.getBoundingClientRect(); + const y = Math.round(e.clientY - bounds.y); + top.style.height = 'calc(' + y + 'px - 1px)'; + split.style.top = 'calc(' + y + 'px - 2px)'; + bottom.style.top = 'calc(' + y + 'px + 1px)'; + settingsPut({ ...settings, topHeightPct: Math.round(100 * bounds.height / splitElem.getBoundingClientRect().height) }); + }); + }), bottom = dom.div(style({ position: 'absolute', bottom: 0, top: 'calc(' + settings.topHeightPct + '% + 1px)', left: 0, right: 0 }), msgElem)); + } + currentLayout = want; + checkMsglistWidth(); + }; + const autoselectLayout = () => { + const want = window.innerWidth <= 2 * 2560 / 3 ? 'topbottom' : 'leftright'; + selectLayout(want); + }; + // When the window size or layout changes, we recalculate the desired widths for + // the msglist "table". It is a list of divs, each with flex layout with 4 elements + // of fixed size. + // Cannot use the CSSStyleSheet constructor with its replaceSync method because + // safari only started implementing it in 2023q1. So we do it the old-fashioned + // way, inserting a style element and updating its style. + const styleElem = dom.style(attr.type('text/css')); + document.head.appendChild(styleElem); + const stylesheet = styleElem.sheet; + let lastmsglistwidth = -1; + const checkMsglistWidth = () => { + const width = msglistscrollElem.getBoundingClientRect().width; + if (lastmsglistwidth === width || width <= 0) { + return; + } + updateMsglistWidths(); + }; + let lastflagswidth, lastagewidth; + let rulesInserted = false; + const updateMsglistWidths = () => { + const width = msglistscrollElem.clientWidth; + lastmsglistwidth = width; + let flagswidth = settings.msglistflagsWidth; + let agewidth = settings.msglistageWidth; + let frompct = settings.msglistfromPct; // Of remaining space. + if (flagswidth + agewidth > width) { + flagswidth = Math.floor(width / 2); + agewidth = width - flagswidth; + } + const remain = width - (flagswidth + agewidth); + const fromwidth = Math.floor(frompct * remain / 100); + const subjectwidth = Math.floor(remain - fromwidth); + const cssRules = [ + ['.msgitemflags', { width: flagswidth }], + ['.msgitemfrom', { width: fromwidth }], + ['.msgitemsubject', { width: subjectwidth }], + ['.msgitemage', { width: agewidth }], + ['.msgitemflagsoffset', { left: flagswidth }], + ['.msgitemfromoffset', { left: flagswidth + fromwidth }], + ['.msgitemsubjectoffset', { left: flagswidth + fromwidth + subjectwidth }], + ]; + if (!rulesInserted) { + cssRules.forEach((rule, i) => { stylesheet.insertRule(rule[0] + '{}', i); }); + rulesInserted = true; + } + cssRules.forEach((rule, i) => { + const r = stylesheet.cssRules[i]; + for (const k in rule[1]) { + r.style.setProperty(k, '' + rule[1][k] + 'px'); + } + }); + lastflagswidth = flagswidth; + lastagewidth = agewidth; + }; + // Select initial layout. + if (layoutElem.value === 'auto') { + autoselectLayout(); + } + else { + selectLayout(layoutElem.value); + } + dom._kids(page, webmailroot); + checkMsglistWidth(); + window.addEventListener('resize', function () { + if (layoutElem.value === 'auto') { + autoselectLayout(); + } + checkMsglistWidth(); + }); + window.addEventListener('hashchange', async () => { + const [search, msgid, f, notf] = parseLocationHash(mailboxlistView); + requestMsgID = msgid; + if (search) { + mailboxlistView.closeMailbox(); + loadSearch(search); + } + else { + unloadSearch(); + await mailboxlistView.openMailboxID(f.MailboxID, false); + } + await withStatus('Requesting messages', requestNewView(false, f, notf)); + }); + let eventSource = null; // If set, we have a connection. + let connecting = false; // Check before reconnecting. + let noreconnect = false; // Set after one reconnect attempt fails. + let noreconnectTimer = 0; // Timer ID for resetting noreconnect. + // Don't show disconnection just before user navigates away. + let leaving = false; + window.addEventListener('beforeunload', () => { + leaving = true; + if (eventSource) { + eventSource.close(); + eventSource = null; + sseID = 0; + } + }); + // On chromium, we may get restored when user hits the back button ("bfcache"). We + // have left, closed the connection, so we should restore it. + window.addEventListener('pageshow', async (e) => { + if (e.persisted && !eventSource && !connecting) { + noreconnect = false; + connect(false); + } + }); + // If user comes back to tab/window, and we are disconnected, try another reconnect. + window.addEventListener('focus', () => { + if (!eventSource && !connecting) { + noreconnect = false; + connect(true); + } + }); + const showNotConnected = () => { + dom._kids(connectionElem, attr.role('status'), dom.span(style({ backgroundColor: '#ffa9a9', padding: '0 .15em', borderRadius: '.15em' }), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), ' ', dom.clickbutton('Reconnect', function click() { + if (!eventSource && !connecting) { + noreconnect = false; + connect(true); + } + })); + }; + const connect = async (isreconnect) => { + connectionElem.classList.toggle('loading', true); + dom._kids(connectionElem); + connectionElem.classList.toggle('loading', false); + // We'll clear noreconnect when we've held a connection for 10 mins. + noreconnect = isreconnect; + connecting = true; + let token; + try { + token = await withStatus('Fetching token for connection with real-time updates', client.Token(), undefined, true); + } + catch (err) { + connecting = false; + noreconnect = true; + dom._kids(statusElem, (err.message || 'error fetching connection token') + ', not automatically retrying'); + showNotConnected(); + return; + } + let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView); + requestMsgID = msgid; + requestFilter = f; + requestNotFilter = notf; + if (searchQuery) { + loadSearch(searchQuery); + } + [f, notf] = refineFilters(requestFilter, requestNotFilter); + const fetchCount = Math.max(50, 3 * Math.ceil(msglistscrollElem.getBoundingClientRect().height / msglistView.itemHeight())); + const query = { + OrderAsc: settings.orderAsc, + Filter: f, + NotFilter: notf, + }; + const page = { + AnchorMessageID: 0, + Count: fetchCount, + DestMessageID: msgid, + }; + viewSequence++; + viewID = viewSequence; + // We get an implicit query for the automatically selected mailbox or query. + requestSequence++; + requestID = requestSequence; + requestViewEnd = false; + clearList(); + const request = { + ID: requestID, + // A new SSEID is created by the server, sent in the initial response message. + ViewID: viewID, + Query: query, + Page: page, + }; + let slow = ''; + try { + const debug = JSON.parse(localStorage.getItem('sherpats-debug') || 'null'); + if (debug && debug.waitMinMsec && debug.waitMaxMsec) { + slow = '&waitMinMsec=' + debug.waitMinMsec + '&waitMaxMsec=' + debug.waitMaxMsec; + } + } + catch (err) { } + eventSource = new window.EventSource('events?token=' + encodeURIComponent(token) + '&request=' + encodeURIComponent(JSON.stringify(request)) + slow); + let eventID = window.setTimeout(() => dom._kids(statusElem, 'Connecting...'), 1000); + eventSource.addEventListener('open', (e) => { + log('eventsource open', { e }); + if (eventID) { + window.clearTimeout(eventID); + eventID = 0; + } + dom._kids(statusElem); + dom._kids(connectionElem); + }); + const sseError = (errmsg) => { + sseID = 0; + eventSource.close(); + eventSource = null; + connecting = false; + if (noreconnectTimer) { + clearTimeout(noreconnectTimer); + noreconnectTimer = 0; + } + if (leaving) { + return; + } + if (eventID) { + window.clearTimeout(eventID); + eventID = 0; + } + document.title = ['(not connected)', loginAddress ? (loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII)) : '', 'Mox Webmail'].filter(s => s).join(' - '); + dom._kids(connectionElem); + if (noreconnect) { + dom._kids(statusElem, errmsg + ', not automatically retrying'); + showNotConnected(); + listloadingElem.remove(); + listendElem.remove(); + } + else { + connect(true); + } + }; + // EventSource-connection error. No details. + eventSource.addEventListener('error', (e) => { + log('eventsource error', { e }, JSON.stringify(e)); + sseError('Connection failed'); + }); + // Fatal error on the server side, error message propagated, but connection needs to be closed. + eventSource.addEventListener('fatalErr', (e) => { + const errmsg = JSON.parse(e.data) || '(no error message)'; + sseError('Server error: "' + errmsg + '"'); + }); + const checkParse = (fn) => { + try { + return fn(); + } + catch (err) { + window.alert('invalid event from server: ' + (err.message || '(no message)')); + throw err; + } + }; + eventSource.addEventListener('start', (e) => { + const start = checkParse(() => api.parser.EventStart(JSON.parse(e.data))); + log('event start', start); + connecting = false; + sseID = start.SSEID; + loginAddress = start.LoginAddress; + const loginAddr = formatEmailASCII(loginAddress); + accountAddresses = start.Addresses || []; + accountAddresses.sort((a, b) => { + if (formatEmailASCII(a) === loginAddr) { + return -1; + } + if (formatEmailASCII(b) === loginAddr) { + return 1; + } + if (a.Domain.ASCII != b.Domain.ASCII) { + return a.Domain.ASCII < b.Domain.ASCII ? -1 : 1; + } + return a.User < b.User ? -1 : 1; + }); + domainAddressConfigs = start.DomainAddressConfigs || {}; + clearList(); + let mailboxName = start.MailboxName; + let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName); + if (mb) { + requestFilter.MailboxID = mb.ID; // For check to display mailboxname in msgitemView. + } + if (mailboxName === '') { + mailboxName = (start.Mailboxes || []).find(mb => mb.ID === requestFilter.MailboxID)?.Name || ''; + } + mailboxlistView.loadMailboxes(start.Mailboxes || [], search.active ? undefined : mailboxName); + if (searchView.root.parentElement) { + searchView.ensureLoaded(); + } + if (!mb) { + updatePageTitle(); + } + dom._kids(queryactivityElem, 'loading...'); + msglistscrollElem.appendChild(listloadingElem); + noreconnectTimer = setTimeout(() => { + noreconnect = false; + noreconnectTimer = 0; + }, 10 * 60 * 1000); + }); + eventSource.addEventListener('viewErr', async (e) => { + const viewErr = checkParse(() => api.parser.EventViewErr(JSON.parse(e.data))); + log('event viewErr', viewErr); + if (viewErr.ViewID != viewID || viewErr.RequestID !== requestID) { + log('received viewErr for other viewID or requestID', { expected: { viewID, requestID }, got: { viewID: viewErr.ViewID, requestID: viewErr.RequestID } }); + return; + } + viewID = 0; + requestID = 0; + dom._kids(queryactivityElem); + listloadingElem.remove(); + listerrElem.remove(); + dom._kids(listerrElem, 'Error from server during request for messages: ' + viewErr.Err); + msglistscrollElem.appendChild(listerrElem); + window.alert('Error from server during request for messages: ' + viewErr.Err); + }); + eventSource.addEventListener('viewReset', async (e) => { + const viewReset = checkParse(() => api.parser.EventViewReset(JSON.parse(e.data))); + log('event viewReset', viewReset); + if (viewReset.ViewID != viewID || viewReset.RequestID !== requestID) { + log('received viewReset for other viewID or requestID', { expected: { viewID, requestID }, got: { viewID: viewReset.ViewID, requestID: viewReset.RequestID } }); + return; + } + clearList(); + dom._kids(queryactivityElem, 'loading...'); + msglistscrollElem.appendChild(listloadingElem); + window.alert('Could not find message to continue scrolling, resetting the view.'); + }); + eventSource.addEventListener('viewMsgs', async (e) => { + const viewMsgs = checkParse(() => api.parser.EventViewMsgs(JSON.parse(e.data))); + log('event viewMsgs', viewMsgs); + if (viewMsgs.ViewID != viewID || viewMsgs.RequestID !== requestID) { + log('received viewMsgs for other viewID or requestID', { expected: { viewID, requestID }, got: { viewID: viewMsgs.ViewID, requestID: viewMsgs.RequestID } }); + return; + } + msglistView.root.classList.toggle('loading', false); + const extramsgitemViews = (viewMsgs.MessageItems || []).map(mi => { + const othermb = requestFilter.MailboxID !== mi.Message.MailboxID ? mailboxlistView.findMailboxByID(mi.Message.MailboxID) : undefined; + return newMsgitemView(mi, msglistView, othermb || null); + }); + msglistView.addMsgitemViews(extramsgitemViews); + if (viewMsgs.ParsedMessage) { + const msgID = viewMsgs.ParsedMessage.ID; + const miv = extramsgitemViews.find(miv => miv.messageitem.Message.ID === msgID); + if (miv) { + msglistView.openMessage(miv, true, viewMsgs.ParsedMessage); + } + else { + // Should not happen, server would be sending a parsedmessage while not including the message itself. + requestMsgID = 0; + setLocationHash(); + } + } + requestViewEnd = viewMsgs.ViewEnd; + if (requestViewEnd) { + msglistscrollElem.appendChild(listendElem); + } + if ((viewMsgs.MessageItems || []).length === 0 || requestViewEnd) { + dom._kids(queryactivityElem); + listloadingElem.remove(); + requestID = 0; + if (requestMsgID) { + requestMsgID = 0; + setLocationHash(); + } + } + }); + eventSource.addEventListener('viewChanges', async (e) => { + const viewChanges = checkParse(() => api.parser.EventViewChanges(JSON.parse(e.data))); + log('event viewChanges', viewChanges); + if (viewChanges.ViewID != viewID) { + log('received viewChanges for other viewID', { expected: viewID, got: viewChanges.ViewID }); + return; + } + try { + (viewChanges.Changes || []).forEach(tc => { + if (!tc) { + return; + } + const [tag, x] = tc; + if (tag === 'ChangeMailboxCounts') { + const c = api.parser.ChangeMailboxCounts(x); + mailboxlistView.setMailboxCounts(c.MailboxID, c.Total, c.Unread); + } + else if (tag === 'ChangeMailboxSpecialUse') { + const c = api.parser.ChangeMailboxSpecialUse(x); + mailboxlistView.setMailboxSpecialUse(c.MailboxID, c.SpecialUse); + } + else if (tag === 'ChangeMailboxKeywords') { + const c = api.parser.ChangeMailboxKeywords(x); + mailboxlistView.setMailboxKeywords(c.MailboxID, c.Keywords || []); + } + else if (tag === 'ChangeMsgAdd') { + const c = api.parser.ChangeMsgAdd(x); + msglistView.addMessageItems([c.MessageItem]); + } + else if (tag === 'ChangeMsgRemove') { + const c = api.parser.ChangeMsgRemove(x); + msglistView.removeUIDs(c.MailboxID, c.UIDs || []); + } + else if (tag === 'ChangeMsgFlags') { + const c = api.parser.ChangeMsgFlags(x); + msglistView.updateFlags(c.MailboxID, c.UID, c.Mask, c.Flags, c.Keywords || []); + } + else if (tag === 'ChangeMailboxRemove') { + const c = api.parser.ChangeMailboxRemove(x); + mailboxlistView.removeMailbox(c.MailboxID); + } + else if (tag === 'ChangeMailboxAdd') { + const c = api.parser.ChangeMailboxAdd(x); + mailboxlistView.addMailbox(c.Mailbox); + } + else if (tag === 'ChangeMailboxRename') { + const c = api.parser.ChangeMailboxRename(x); + mailboxlistView.renameMailbox(c.MailboxID, c.NewName); + } + else { + throw new Error('unknown change tag ' + tag); + } + }); + } + catch (err) { + window.alert('Error processing changes (reloading advised): ' + errmsg(err)); + } + }); + }; + connect(false); +}; +window.addEventListener('load', async () => { + try { + await init(); + } + catch (err) { + window.alert('Error: ' + errmsg(err)); + } +}); +// If a JS error happens, show a box in the lower left corner, with a button to +// show details, in a popup. The popup shows the error message and a link to github +// to create an issue. We want to lower the barrier to give feedback. +const showUnhandledError = (err, lineno, colno) => { + console.log('unhandled error', err); + if (settings.ignoreErrorsUntil > new Date().getTime() / 1000) { + return; + } + let stack = err.stack || ''; + if (stack) { + // Firefox has stacks with full location.href including hash at the time of + // writing, Chromium has location.href without hash. + const loc = window.location; + stack = '\n' + stack.replaceAll(loc.href, 'webmail.html').replaceAll(loc.protocol + '//' + loc.host + loc.pathname + loc.search, 'webmail.html'); + } + else { + stack = ' (not available)'; + } + const xerrmsg = err.toString(); + const box = dom.div(style({ position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(249, 191, 191, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut }), dom.div(style({ marginBottom: '.5ex' }), '' + xerrmsg), dom.clickbutton('Details', function click() { + box.remove(); + let msg = `Mox version: ${moxversion} +Browser: ${window.navigator.userAgent} +File: webmail.html +Lineno: ${lineno || '-'} +Colno: ${colno || '-'} +Message: ${xerrmsg} + +Stack trace: ${stack} +`; + const body = `[Hi! Please replace this text with an explanation of what you did to trigger this errors. It will help us reproduce the problem. The more details, the more likely it is we can find and fix the problem. If you don't know how or why it happened, that's ok, it is still useful to report the problem. If no stack trace was found and included below, and you are a developer, you can probably find more details about the error in the browser developer console. Thanks!] + +Details of the error and browser: + +` + '```\n' + msg + '```\n'; + const remove = popup(style({ maxWidth: '60em' }), dom.h1('A JavaScript error occurred'), dom.pre(dom._class('mono'), style({ backgroundColor: '#f8f8f8', padding: '1ex', borderRadius: '.15em', border: '1px solid #ccc', whiteSpace: 'pre-wrap' }), msg), dom.br(), dom.div('There is a good chance this is a bug in Mox Webmail.'), dom.div('Consider filing a bug report ("issue") at ', link('https://github.com/mjl-/mox/issues/new?title=' + encodeURIComponent('mox webmail js error: "' + xerrmsg + '"') + '&body=' + encodeURIComponent(body), 'https://github.com/mjl-/mox/issues/new'), '. The link includes the error details.'), dom.div('Before reporting you could check previous ', link('https://github.com/mjl-/mox/issues?q=is%3Aissue+"mox+webmail+js+error%3A"', 'webmail bug reports'), '.'), dom.br(), dom.div('Your feedback will help improve mox, thanks!'), dom.br(), dom.div(style({ textAlign: 'right' }), dom.clickbutton('Close and silence errors for 1 week', function click() { + remove(); + settingsPut({ ...settings, ignoreErrorsUntil: Math.round(new Date().getTime() / 1000 + 7 * 24 * 3600) }); + }), ' ', dom.clickbutton('Close', function click() { + remove(); + }))); + }), ' ', dom.clickbutton('Ignore', function click() { + box.remove(); + })); + document.body.appendChild(box); +}; +// We don't catch all errors, we use throws to not continue executing javascript. +// But for JavaScript-level errors, we want to show a warning to helpfully get the +// user to submit a bug report. +window.addEventListener('unhandledrejection', (e) => { + if (!e.reason) { + return; + } + const err = e.reason; + if (err instanceof EvalError || err instanceof RangeError || err instanceof ReferenceError || err instanceof SyntaxError || err instanceof TypeError || err instanceof URIError) { + showUnhandledError(err, 0, 0); + } + else { + console.log('unhandled promiserejection', err, e.promise); + } +}); +// Window-level errors aren't that likely, since all code is in the init promise, +// but doesn't hurt to register an handler. +window.addEventListener('error', e => { + showUnhandledError(e.error, e.lineno, e.colno); +}); diff --git a/webmail/webmail.ts b/webmail/webmail.ts new file mode 100644 index 0000000..80f2a11 --- /dev/null +++ b/webmail/webmail.ts @@ -0,0 +1,5095 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +/* +Webmail is a self-contained webmail client. + +Typescript is used for type safety, but otherwise we try not to rely on any +JS/TS tools/frameworks etc, they often complicate/obscure how things work. The +DOM and styles are directly manipulated, so to develop on this code you need to +know about DOM functions. With a few helper functions in the dom object, +interaction with the DOM is still relatively high-level, but also allows for +more low-level techniques like rendering of text in a way that highlights text +that switches unicode blocks/scripts. We use typescript in strict mode, see +top-level tsc.sh. We often specify types for function parameters, but not +return types, since typescript is good at deriving return types. + +There is no mechanism to automatically update a view when properties change. The +UI is split/isolated in components called "views", which expose only their root +HTMLElement for inclusion in another component or the top-level document. A view +has functions that other views (e.g. parents) can call for to propagate updates +or retrieve data. We have these views: + +- Mailboxlist, in the bar on the list with all mailboxes. +- Mailbox, a single mailbox in the mailbox list. +- Search, with form for search criteria, opened through search bar. +- Msglist, the list of messages for the selected mailbox or search query. +- Msgitem, a message in Msglist, shown as a single line. +- Msg, showing the contents of a single selected message. +- Compose, when writing a new message (or reply/forward). + +Most of the data is transferred over an SSE connection. It sends the initial +list of mailboxes, sends message summaries for the currently selected mailbox or +search query and sends changes as they happen, e.g. added/removed messages, +changed flags, etc. Operations that modify data are done through API calls. The +typescript API is generated from the Go types and functions. Displayed message +contents are also retrieved through an API call. + +HTML messages are potentially dangerous. We display them in a separate iframe, +with contents served in a separate HTTP request, with Content-Security-Policy +headers that prevent executing scripts or loading potentially unwanted remote +resources. We cannot load the HTML in an inline iframe, because the iframe "csp" +attribute to set a Content-Security-Policy is not supported by all modern +browsers (safari and firefox don't support it at the time of writing). Text +messages are rendered inside the webmail client, making URLs clickable, +highlighting unicode script/block changes and rendering quoted text in a +different color. + +Browsers to test with: Firefox, Chromium, Safari, Edge. + +To simulate slow API calls and SSE events: +window.localStorage.setItem('sherpats-debug', JSON.stringify({waitMinMsec: 2000, waitMaxMsec: 4000})) + +Show additional headers of messages: +settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id']}) + +- todo: threading (needs support in mox first) +- todo: in msglistView, show names of people we have sent to, and address otherwise. +- todo: implement settings stored in the server, such as mailboxCollapsed, keyboard shortcuts. also new settings for displaying email as html by default for configured sender address or domain. name to use for "From", optional default Reply-To and Bcc addresses, signatures (per address), configured labels/keywords with human-readable name, colors and toggling with shortcut keys 1-9. +- todo: in msglist, if our address is in the from header, list addresses in the to/cc/bcc, it's likely a sent folder +- todo: automated tests? perhaps some unit tests, then ui scenario's. +- todo: compose, wrap lines +- todo: composing of html messages. possibly based on contenteditable. would be good if we can include original html, but quoted. must make sure to not include dangerous scripts/resources, or sandbox it. +- todo: make alt up/down keys work on html iframe too. requires loading it from sameorigin, to get access to its inner document. +- todo: reconnect with last known modseq and don't clear the message list, only update it +- todo: resize and move of compose window +- todo: find and use svg icons for flags in the msgitemView. junk (fire), forwarded, replied, attachment (paperclip), flagged (flag), phishing (?). also for special-use mailboxes (junk, trash, archive, draft, sent). should be basic and slim. +- todo: for embedded messages (message/rfc822 or message/global), allow viewing it as message, perhaps in a popup? +- todo: for content-disposition: inline, show images alongside text? +- todo: only show orange underline where it could be a problem? in addresses and anchor texts. we may be lighting up a christmas tree now, desensitizing users. +- todo: saved searches that are displayed below list of mailboxes, for quick access to preset view +- todo: when search on free-form text is active, highlight the searched text in the message view. +- todo: when reconnecting, request only the changes to the current state/msglist, passing modseq query string parameter +- todo: composeView: save as draft, periodically and when closing. +- todo: forwarding of html parts, including inline attachments, so the html version can be rendered like the original by the receiver. +- todo: buttons/mechanism to operate on all messages in a mailbox/search query, without having to list and select all messages. e.g. clearing flags/labels. +- todo: can we detect if browser supports proper CSP? if not, refuse to load html messages? +- todo: more search criteria? Date header field (instead of time received), text vs html (only, either or both), attachment filenames and sizes +- todo: integrate more of the account page into webmail? importing/exporting messages, configuring delivery rules (possibly with sieve). for messages moved out of inbox to non-special-use mailbox, show button that helps make an automatic rule to move such messages again (e.g. based on message From address, message From domain or List-ID header). +- todo: configurable keyboard shortcuts? we use strings like "ctrl p" which we already generate and match on, add a mapping from command name to cmd* functions, and have a map of keys to command names. the commands for up/down with shift/ctrl modifiers may need special attention. +- todo: nicer address input fields like other mail clients do. with tab to autocomplete and turn input into a box and delete removing of the entire address. +- todo: consider composing messages with bcc headers that are kept as message Bcc headers, optionally with checkbox. +- todo: improve accessibility +- todo: msglistView: preload next message? +- todo: previews of zip files +- todo: undo? +- todo: mute threads? +- todo: mobile-friendly version. should perhaps be a completely different app, because it is so different. +- todo: msglistView: for mailbox views (which are fast to list the results of), should we ask the full number of messages, set the height of the scroll div based on the number of messages, then request messages when user scrolls, putting the messages in place. not sure if worth the trouble. +- todo: basic vim key bindings in textarea/input. or just let users use a browser plugin. +*/ + +const zindexes = { + splitter: '1', + compose: '2', + searchView: '3', + searchbar: '4', + popup: '5', + popover: '5', + attachments: '5', + shortcut: '6', +} + +// From HTML. +declare let page: HTMLElement +declare let moxversion: string + +// All logging goes through log() instead of console.log, except "should not happen" logging. +let log: (...args: any[]) => void = () => {} +try { + if (localStorage.getItem('log')) { + log = console.log + } +} catch (err) {} + +const defaultSettings = { + showShortcuts: true, // Whether to briefly show shortcuts in bottom left when a button is clicked that has a keyboard shortcut. + mailboxesWidth: 240, + layout: 'auto', // Automatic switching between left/right and top/bottom layout, based on screen width. + leftWidthPct: 50, // Split in percentage of remaining width for left/right layout. + topHeightPct: 40, // Split in percentage of remaining height for top/bottom layout. + msglistflagsWidth: 40, // Width in pixels of flags column in message list. + msglistageWidth: 70, // Width in pixels of age column. + msglistfromPct: 30, // Percentage of remaining width in message list to use for "from" column. The remainder is for the subject. + refine: '', // Refine filters, e.g. '', 'attachments', 'read', 'unread', 'label:...'. + orderAsc: false, // Order from most recent to least recent by default. + ignoreErrorsUntil: 0, // For unhandled javascript errors/rejected promises, we normally show a popup for details, but users can ignore them for a week at a time. + showHTML: false, // Whether we show HTML version of email instead of plain text if both are present. + mailboxCollapsed: {} as {[mailboxID: number]: boolean}, // Mailboxes that are collapsed. + showAllHeaders: false, // Whether to show all message headers. + showHeaders: [] as string[], // Additional message headers to show. +} +const parseSettings = (): typeof defaultSettings => { + try { + const v = window.localStorage.getItem('settings') + if (!v) { + return {...defaultSettings} + } + const x = JSON.parse(v) + const def: {[key: string]: any} = defaultSettings + const getString = (k: string, ...l: string[]): string => { + const v = x[k] + if (typeof v !== 'string' || l.length > 0 && !l.includes(v)) { + return def[k] as string + } + return v + } + const getBool = (k: string): boolean => { + const v = x[k] + return typeof v === 'boolean' ? v : def[k] as boolean + } + const getInt = (k: string): number => { + const v = x[k] + return typeof v === 'number' ? v : def[k] as number + } + let mailboxCollapsed: {[mailboxID: number]: boolean} = x.mailboxCollapsed + if (!mailboxCollapsed || typeof mailboxCollapsed !== 'object') { + mailboxCollapsed = def.mailboxCollapsed + } + const getStringArray = (k: string): string[] => { + const v = x[k] + if (v && Array.isArray(v) && (v.length === 0 || typeof v[0] === 'string')) { + return v + } + return def[k] as string[] + } + + return { + refine: getString('refine'), + orderAsc: getBool('orderAsc'), + mailboxesWidth: getInt('mailboxesWidth'), + leftWidthPct: getInt('leftWidthPct'), + topHeightPct: getInt('topHeightPct'), + msglistflagsWidth: getInt('msglistflagsWidth'), + msglistageWidth: getInt('msglistageWidth'), + msglistfromPct: getInt('msglistfromPct'), + ignoreErrorsUntil: getInt('ignoreErrorsUntil'), + layout: getString('layout', 'auto', 'leftright', 'topbottom'), + showShortcuts: getBool('showShortcuts'), + showHTML: getBool('showHTML'), + mailboxCollapsed: mailboxCollapsed, + showAllHeaders: getBool('showAllHeaders'), + showHeaders: getStringArray('showHeaders'), + } + } catch (err) { + console.log('getting settings from localstorage', err) + return {...defaultSettings} + } +} + +// Store new settings. Called as settingsPut({...settings, updatedField: newValue}). +const settingsPut = (nsettings: typeof defaultSettings) => { + settings = nsettings + try { + window.localStorage.setItem('settings', JSON.stringify(nsettings)) + } catch (err) { + console.log('storing settings in localstorage', err) + } +} + +let settings = parseSettings() + +// All addresses for this account, can include "@domain" wildcard, User is empty in +// that case. Set when SSE connection is initialized. +let accountAddresses: api.MessageAddress[] = [] + +// Username/email address of login. Used as default From address when composing +// a new message. +let loginAddress: api.MessageAddress | null = null + +// Localpart config (catchall separator and case sensitivity) for each domain +// the account has an address for. +let domainAddressConfigs: {[domainASCII: string]: api.DomainAddressConfig} = {} + +const client = new api.Client() + +// Link returns a clickable link with rel="noopener noreferrer". +const link = (href: string, anchorOpt?: string): HTMLElement => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href) + +// Returns first own account address matching an address in l. +const envelopeIdentity = (l: api.MessageAddress[]): api.MessageAddress | null => { + for (const a of l) { + const ma = accountAddresses.find(aa => (!aa.User || aa.User === a.User) && aa.Domain.ASCII === a.Domain.ASCII) + if (ma) { + return {Name: ma.Name, User: a.User, Domain: a.Domain} + } + } + return null +} + +// We can display keyboard shortcuts when a user clicks a button that has a shortcut. +let shortcutElem = dom.div(style({fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em'})) +let shortcutTimer = 0 +const showShortcut = (c: string) => { + if (!settings.showShortcuts) { + return + } + if (shortcutTimer) { + window.clearTimeout(shortcutTimer) + } + shortcutElem.remove() + dom._kids(shortcutElem, c) + document.body.appendChild(shortcutElem) + shortcutTimer = setTimeout(() => { + shortcutElem.remove() + shortcutTimer = 0 + }, 1500) +} + +// Commands for buttons that can have a shortcut. +type command = () => Promise + +// Call cmdfn and display the shortcut for the command if it occurs in shortcuts. +const shortcutCmd = async (cmdfn: command, shortcuts: {[key: string]: command}) => { + let shortcut = '' + for (const k in shortcuts) { + if (shortcuts[k] == cmdfn) { + shortcut = k + break + } + } + if (shortcut) { + showShortcut(shortcut) + } + await cmdfn() +} + +// clickCmd returns a click handler that runs a cmd and shows its shortcut. +const clickCmd = (cmdfn: command, shortcuts: {[key: string]: command}) => { + return async function click() { + shortcutCmd(cmdfn, shortcuts) + } +} + +// enterCmd returns a keydown handler that runs a cmd when Enter is pressed and shows its shortcut. +const enterCmd = (cmdfn: command, shortcuts: {[key: string]: command}) => { + return async function keydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.stopPropagation() + shortcutCmd(cmdfn, shortcuts) + } + } +} + +// keyHandler returns a function that handles keyboard events for a map of +// shortcuts, calling the shortcut function if found. +const keyHandler = (shortcuts: {[key: string]: command}) => { + return async (k: string, e: KeyboardEvent) => { + const fn = shortcuts[k] + if (fn) { + e.preventDefault() + e.stopPropagation() + fn() + } + } +} + +// For attachment sizes. +const formatSize = (size: number) => size > 1024*1024 ? (size/(1024*1024)).toFixed(1)+'mb' : Math.ceil(size/1024)+'kb' + +// Parse size as used in minsize: and maxsize: in the search bar. +const parseSearchSize = (s: string): [string, number] => { + s = s.trim() + if (!s) { + return ['', 0] + } + const digits = s.match(/^([0-9]+)/)?.[1] + if (!digits) { + return ['', 0] + } + let num = parseInt(digits) + if (isNaN(num)) { + return ['', 0] + } + const suffix = s.substring(digits.length).trim().toLowerCase() + if (['b', 'kb', 'mb', 'gb'].includes(suffix)) { + return [digits+suffix, num*Math.pow(2, 10*['b', 'kb', 'mb', 'gb'].indexOf(suffix))] + } + if (['k', 'm', 'g'].includes(suffix)) { + return [digits+suffix+'b', num*Math.pow(2, 10*(1+['k', 'm', 'g'].indexOf(suffix)))] + } + return ['', 0] +} + +// JS date does not allow months and days as single digit, it requires a 0 +// prefix in those cases, so fix up such dates. +const fixDate = (dt: string): string => { + const t = dt.split('-') + if (t.length !== 3) { + return dt + } + if(t[1].length === 1) { + t[1] = '0'+t[1] + } + if(t[2].length === 1) { + t[2] = '0'+t[2] + } + return t.join('-') +} + +// Parse date and/or time, for use in searchbarElem with start: and end:. +const parseSearchDateTime = (s: string, isstart: boolean): string | undefined => { + const t = s.split('T', 2) + if (t.length === 2) { + const d = new Date(fixDate(t[0]) + 'T'+t[1]) + return d ? d.toJSON() : undefined + } else if (t.length === 1) { + if (isNaN(Date.parse(fixDate(t[0])))) { + const d = new Date(fixDate(t[0])) + if (!isstart) { + d.setDate(d.getDate()+1) + } + return d.toJSON() + } else { + const tm = t[0] + const now = new Date() + const pad0 = (v: number) => v <= 9 ? '0'+v : ''+v + const d = new Date([now.getFullYear(), pad0(now.getMonth()+1), pad0(now.getDate())].join('-')+'T'+tm) + return d ? d.toJSON() : undefined + } + } + return undefined +} + +// The searchbarElem is parsed into tokens, each with: minus prefix ("not" match), +// a tag (e.g. "minsize" in "minsize:1m"), a string, and whether the string was +// quoted (text that starts with a dash or looks like a tag needs to be quoted). A +// final ending quote is implicit. All input can be parsed into tokens, there is no +// invalid syntax (at most unexpected parsing). +type Token = [boolean, string, boolean, string] + +const dquote = (s: string): string => '"' + s.replaceAll('"', '""') + '"' +const needsDquote = (s: string): boolean => /[ \t"]/.test(s) +const packToken = (t: Token): string => (t[0] ? '-' : '') + (t[1] ? t[1]+':' : '') + (t[2] || needsDquote(t[3]) ? dquote(t[3]) : t[3]) + +// Parse the text from the searchbarElem into tokens. All input is valid. +const parseSearchTokens = (s: string): Token[] => { + if (!s) { + return [] + } + const l: Token[] = [] // Tokens we gathered. + + let not = false + let quoted = false // If double quote was seen. + let quoteend = false // Possible closing quote seen. Can also be escaped quote. + let t = '' // Current token. We only keep non-empty tokens. + let tquoted = false // If t started out quoted. + const add = () => { + if (t && (tquoted || !t.includes(':'))) { + l.push([not, '', tquoted, t]) + } else if (t) { + const tag = t.split(':', 1)[0] + l.push([not, tag, tquoted, t.substring(tag.length+1)]) + } + t = '' + quoted = false + quoteend = false + tquoted = false + not = false + } + ;[...s].forEach(c => { + if (quoteend) { + if (c === '"') { + t += '"' + quoteend = false + } else if (t) { + add() + } + } else if (quoted && c == '"') { + quoteend = true + } else if (c === '"') { + quoted = true + if (!t) { + tquoted = true + } + } else if (!quoted && (c === ' ' || c === '\t')) { + add() + } else if (c === '-' && !t && !tquoted && !not) { + not = true + } else { + t += c + } + }) + add() + return l +} + +// returns a filter with empty/zero required fields. +const newFilter = (): api.Filter => { + return { + MailboxID: 0, + MailboxChildrenIncluded: false, + MailboxName: '', + Attachments: api.AttachmentType.AttachmentIndifferent, + SizeMin: 0, + SizeMax: 0, + } +} +const newNotFilter = (): api.NotFilter => { + return { + Attachments: api.AttachmentType.AttachmentIndifferent, + } +} + +// We keep the original strings typed in by the user, we don't send them to the +// backend, so we keep them separately from api.Filter. +type FilterStrs = { + Oldest: string + Newest: string + SizeMin: string + SizeMax: string +} + +// Parse search bar into filters that we can use to populate the form again, or +// send to the server. +const parseSearch = (searchquery: string, mailboxlistView: MailboxlistView): [api.Filter, api.NotFilter, FilterStrs] => { + const tokens = parseSearchTokens(searchquery) + + const fpos = newFilter() + fpos.MailboxID = -1 // All mailboxes excluding Trash/Junk/Rejects. + const notf = newNotFilter() + const strs = {Oldest: '', Newest: '', SizeMin: '', SizeMax: ''} + tokens.forEach(t => { + const [not, tag, _, s] = t + const f = not ? notf : fpos + + if (!not) { + if (tag === 'mb' || tag === 'mailbox') { + const mb = mailboxlistView.findMailboxByName(s) + if (mb) { + fpos.MailboxID = mb.ID + } else if (s === '') { + fpos.MailboxID = 0 // All mailboxes, including Trash/Junk/Rejects. + } else { + fpos.MailboxName = s + fpos.MailboxID = 0 + } + return + } else if (tag == 'submb') { + fpos.MailboxChildrenIncluded = true + return + } else if (tag === 'start') { + const dt = parseSearchDateTime(s, true) + if (dt) { + fpos.Oldest = new Date(dt) + strs.Oldest = s + return + } + } else if (tag === 'end') { + const dt = parseSearchDateTime(s, false) + if (dt) { + fpos.Newest = new Date(dt) + strs.Newest = s + return + } + } else if (tag === 'a' || tag === 'attachments') { + if (s === 'none' || s === 'any' || s === 'image' || s === 'pdf' || s === 'archive' || s === 'zip' || s === 'spreadsheet' || s === 'document' || s === 'presentation') { + fpos.Attachments = s as api.AttachmentType + return + } + } else if (tag === 'h' || tag === 'header') { + const k = s.split(':')[0] + const v = s.substring(k.length+1) + if (!fpos.Headers) { + fpos.Headers = [[k, v]] + } else { + fpos.Headers.push([k, v]) + } + return + } else if (tag === 'minsize') { + const [str, size] = parseSearchSize(s) + if (str) { + fpos.SizeMin = size + strs.SizeMin = str + return + } + } else if (tag === 'maxsize') { + const [str, size] = parseSearchSize(s) + if (str) { + fpos.SizeMax = size + strs.SizeMax = str + return + } + } + } + if (tag === 'f' || tag === 'from') { + f.From = f.From || [] + f.From.push(s) + return + } else if (tag === 't' || tag === 'to') { + f.To = f.To || [] + f.To.push(s) + return + } else if (tag === 's' || tag === 'subject') { + f.Subject = f.Subject || [] + f.Subject.push(s) + return + } else if (tag === 'l' || tag === 'label') { + f.Labels = f.Labels || [] + f.Labels.push(s) + return + } + f.Words = f.Words || [] + f.Words.push((tag ? tag+':' : '') + s) + }) + return [fpos, notf, strs] +} + +// Errors in catch statements are of type unknown, we normally want its +// message. +const errmsg = (err: unknown) => ''+((err as any).message || '(no error message)') + +// Return keydown handler that creates or updates the datalist of its target with +// autocompletion addresses. The tab key completes with the first selection. +let datalistgen = 1 +const newAddressComplete = (): any => { + let datalist: HTMLElement + let completeMatches: string[] | null + let completeSearch: string + let completeFull: boolean + + return async function keydown(e: KeyboardEvent) { + const target = e.target as HTMLInputElement + if (!datalist) { + datalist = dom.datalist(attr.id('list-'+datalistgen++)) + target.parentNode!.insertBefore(datalist, target) + target.setAttribute('list', datalist.id) + } + + const search = target.value + + if (e.key === 'Tab') { + const matches = (completeMatches || []).filter(s => s.includes(search)) + if (matches.length > 0) { + target.value = matches[0] + return + } else if ((completeMatches || []).length === 0 && !search) { + return + } + } + + if (completeSearch && search.includes(completeSearch) && completeFull) { + dom._kids(datalist, (completeMatches || []).filter(s => s.includes(search)).map(s => dom.option(s))) + return + } else if (search === completeSearch) { + return + } + try { + [completeMatches, completeFull] = await withStatus('Autocompleting addresses', client.CompleteRecipient(search)) + completeSearch = search + dom._kids(datalist, (completeMatches || []).map(s => dom.option(s))) + } catch (err) { + log('autocomplete error', errmsg(err)) + } + } +} + +// Characters we display in the message list for flags set for a message. +// todo: icons would be nice to have instead. +const flagchars: {[key: string]: string} = { + Replied: 'r', + Flagged: '!', + Forwarded: 'f', + Junk: 'j', + Deleted: 'D', + Draft: 'd', + Phishing: 'p', +} +const flagList = (m: api.Message, mi: api.MessageItem): HTMLElement[] => { + let l: [string, string][] = [] + + const flag = (v: boolean, char: string, name: string) => { + if (v) { + l.push([name, char]) + } + } + flag(m.Answered, 'r', 'Replied/answered') + flag(m.Flagged, '!', 'Flagged') + flag(m.Forwarded, 'f', 'Forwarded') + flag(m.Junk, 'j', 'Junk') + flag(m.Deleted, 'D', 'Deleted, used in IMAP, message will likely be removed soon.') + flag(m.Draft, 'd', 'Draft') + flag(m.Phishing, 'p', 'Phishing') + flag(!m.Junk && !m.Notjunk, '?', 'Unclassified, neither junk nor not junk: message does not contribute to spam classification of new incoming messages') + flag(mi.Attachments && mi.Attachments.length > 0 ? true : false, 'a', 'Has at least one attachment') + return l.map(t => dom.span(dom._class('msgitemflag'), t[1], attr.title(t[0]))) +} + +// Turn filters from the search bar into filters with the refine filters (buttons +// above message list) applied, to send to the server in a request. The original +// filters are not modified. +const refineFilters = (f: api.Filter, notf: api.NotFilter): [api.Filter, api.NotFilter] => { + const refine = settings.refine + if (refine) { + f = {...f} + notf = {...notf} + if (refine === 'unread') { + notf.Labels = [...(notf.Labels || [])] + notf.Labels = (notf.Labels || []).concat(['\\Seen']) + } else if (refine === 'read') { + f.Labels = [...(f.Labels || [])] + f.Labels = (f.Labels || []).concat(['\\Seen']) + } else if (refine === 'attachments') { + f.Attachments = 'any' as api.AttachmentType + } else if (refine.startsWith('label:')) { + f.Labels = [...(f.Labels || [])] + f.Labels = (f.Labels || []).concat([refine.substring('label:'.length)]) + } + } + return [f, notf] +} + +// For dragging the splitter bars. This function should be called on mousedown. e +// is the mousedown event. Move is the function to call when the bar was dragged, +// typically adjusting styling, e.g. absolutely positioned offsets, possibly based +// on the event.clientX and element bounds offset. +const startDrag = (e: MouseEvent, move: (e: MouseEvent) => void): void => { + if (e.buttons === 1) { + e.preventDefault() + e.stopPropagation() + const stop = () => { + document.body.removeEventListener('mousemove', move) + document.body.removeEventListener('mouseup', stop) + } + document.body.addEventListener('mousemove', move) + document.body.addEventListener('mouseup', stop) + } +} + +// Returns two handler functions: one for focus that sets a placeholder on the +// target element, and one for blur that restores/clears it again. Keeps forms uncluttered, +// only showing contextual help just before you start typing. +const focusPlaceholder = (s: string): any[] => { + let orig = '' + return [ + function focus(e: FocusEvent) { + const target = (e.target! as HTMLElement) + orig = target.getAttribute('placeholder') || '' + target.setAttribute('placeholder', s) + }, + function blur(e: FocusEvent) { + const target = (e.target! as HTMLElement) + if (orig) { + target.setAttribute('placeholder', orig) + } else { + target.removeAttribute('placeholder') + } + }, + ] +} + +// Parse a location hash into search terms (if any), selected message id (if +// any) and filters. +// Optional message id at the end, with ",". +// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1" +const parseLocationHash = (mailboxlistView: MailboxlistView): [string | undefined, number, api.Filter, api.NotFilter] => { + let hash = decodeURIComponent((window.location.hash || '#').substring(1)) + const m = hash.match(/,([0-9]+)$/) + let msgid = 0 + if (m) { + msgid = parseInt(m[1]) + hash = hash.substring(0, hash.length-(','.length+m[1].length)) + } + let initmailbox, initsearch + if (hash.startsWith('search ')) { + initsearch = hash.substring('search '.length).trim() + } + let f: api.Filter, notf: api.NotFilter + if (initsearch) { + [f, notf, ] = parseSearch(initsearch, mailboxlistView) + } else { + initmailbox = hash + if (!initmailbox) { + initmailbox = 'Inbox' + } + f = newFilter() + const mb = mailboxlistView.findMailboxByName(initmailbox) + if (mb) { + f.MailboxID = mb.ID + } else { + f.MailboxName = initmailbox + } + notf = newNotFilter() + } + return [initsearch, msgid, f, notf] +} + +// For HTMLElements like fieldset, input, buttons. We make it easy to disable +// elements while the API call they initiated is still in progress. Prevents +// accidental duplicate API call for twitchy clickers. +interface Disablable { + disabled: boolean +} + +// When API calls are made, we start displaying what we're doing after 1 second. +// Hopefully the command has completed by then, but slow operations, or in case of +// high latency, we'll start showing it. And hide it again when done. This should +// give a non-cluttered instant feeling most of the time, but informs the user when +// needed. +let statusElem: HTMLElement +const withStatus = async (action: string, promise: Promise, disablable?: Disablable, noAlert?: boolean): Promise => { + let elem: HTMLElement | undefined + let id = window.setTimeout(() => { + elem = dom.span(action+'...') + statusElem.appendChild(elem) + id = 0 + }, 1000) + // Could be the element we are going to disable, causing it to lose its focus. We'll restore afterwards. + let origFocus = document.activeElement + try { + if (disablable) { + disablable.disabled = true + } + return await promise + } catch (err) { + if (id) { + window.clearTimeout(id) + id = 0 + } + // Generated by client for aborted requests, e.g. for api.ParsedMessage when loading a message. + if ((err as any).code === 'sherpa:aborted') { + throw err + } + if (!noAlert) { + window.alert('Error: ' + action + ': ' + errmsg(err)) + } + // We throw the error again. The ensures callers that await their withStatus call + // won't continue executing. We have a global handler for uncaught promises, but it + // only handles javascript-level errors, not api call/operation errors. + throw err + } finally { + if (disablable) { + disablable.disabled = false + } + if (origFocus && document.activeElement !== origFocus && origFocus instanceof HTMLElement) { + origFocus.focus() + } + if (id) { + window.clearTimeout(id) + } + if (elem) { + elem.remove() + } + } +} + +// Popover shows kids in a div on top of a mostly transparent overlay on top of +// the document. If transparent is set, the div the kids are in will not get a +// white background. If focus is set, it will be called after adding the +// popover change focus to it, instead of focusing the popover itself. +// Popover returns a function that removes the popover. Clicking the +// transparent overlay, or hitting Escape, closes the popover. +// The div with the kids is positioned around mouse event e, preferably +// towards the right and bottom. But when the position is beyond 2/3's of the +// width or height, it is positioned towards the other direction. The div with +// kids is scrollable if needed. +const popover = (target: HTMLElement, opts: {transparent?: boolean, fullscreen?: boolean}, ...kids: HTMLElement[]) => { + const origFocus = document.activeElement + const pos = target.getBoundingClientRect() + const close = () => { + if (!root.parentNode) { + return + } + root.remove() + if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { + origFocus.focus() + } + } + + const posx = opts.fullscreen ? + style({left: 0, right: 0}) : + ( + pos.x < window.innerWidth/3 ? + style({left: ''+(pos.x)+'px'}) : + style({right: ''+(window.innerWidth - pos.x - pos.width)+'px'}) + ) + const posy = opts.fullscreen ? + style({top: 0, bottom: 0}) : + ( + pos.y+pos.height > window.innerHeight*2/3 ? + style({bottom: ''+(window.innerHeight - (pos.y-1))+'px', maxHeight: ''+(pos.y - 1 - 10)+'px'}) : + style({top: ''+(pos.y+pos.height+1)+'px', maxHeight: ''+(window.innerHeight - (pos.y+pos.height+1) - 10)+'px'}) + ) + + let content: HTMLElement + const root = dom.div( + style({position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: 'rgba(0, 0, 0, 0.2)'}), + function click(e: MouseEvent) { + e.stopPropagation() + close() + }, + function keydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + close() + } + }, + content=dom.div( + attr.tabindex('0'), + style({ + position: 'absolute', + overflowY: 'auto', + }), + posx, posy, + opts.transparent ? [] : [ + style({ + backgroundColor: 'white', + padding: '1em', + borderRadius: '.15em', + boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', + }), + function click(e: MouseEvent) { + e.stopPropagation() + }, + ], + ...kids, + ), + ) + document.body.appendChild(root) + const first = root.querySelector('input, select, textarea, button') + if (first && first instanceof HTMLElement) { + first.focus() + } else { + content.focus() + } + return close +} + +// Popup shows kids in a centered div with white background on top of a +// transparent overlay on top of the window. Clicking the overlay or hitting +// Escape closes the popup. Scrollbars are automatically added to the div with +// kids. Returns a function that removes the popup. +// While a popup is open, no global keyboard shortcuts are handled. Popups get +// to handle keys themselves, e.g. for scrolling. +let popupOpen = false +const popup = (...kids: ElemArg[]) => { + const origFocus = document.activeElement + const close = () => { + if (!root.parentNode) { + return + } + popupOpen = false + root.remove() + if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { + origFocus.focus() + } + } + let content: HTMLElement + const root = dom.div( + style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0, 0, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup}), + function keydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + close() + } + }, + function click(e: MouseEvent) { + e.stopPropagation() + close() + }, + content=dom.div( + attr.tabindex('0'), + style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto'}), + function click(e: MouseEvent) { + e.stopPropagation() + }, + kids, + ) + ) + popupOpen = true + document.body.appendChild(root) + content.focus() + return close +} + +// Show help popup, with shortcuts and basic explanation. +const cmdHelp = async () => { + const remove = popup( + style({padding: '1em 1em 2em 1em'}), + dom.h1('Help and keyboard shortcuts'), + dom.div(style({display: 'flex'}), + dom.div( + style({width: '40em'}), + dom.table( + dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({margin: '0'})))), + [ + ['c', 'compose new message'], + ['/', 'search'], + ['i', 'open inbox'], + ['?', 'help'], + ['ctrl ?', 'tooltip for focused element'], + ['M', 'focus message'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + + dom.tr(dom.td(attr.colspan('2'), dom.h2('Mailbox', style({margin: '0'})))), + [ + ['←', 'collapse'], + ['→', 'expand'], + ['b', 'show more actions'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + + dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({margin: '1ex 0 0 0'})))), + dom.tr( + dom.td('↓', ', j'), + dom.td('down one message'), + dom.td(attr.rowspan('6'), style({color: '#888', borderLeft: '2px solid #ddd', paddingLeft: '.5em'}), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection'), + ), + [ + [['↑', ', k'], 'up one message'], + ['PageDown, l', 'down one screen'], + ['PageUp, h', 'up one screen'], + ['End, .', 'to last message'], + ['Home, ,', 'to first message'], + ['Space', 'toggle selection of message'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + + [ + ['', ''], + ['d, Delete', 'move to trash folder'], + ['D', 'delete permanently'], + ['q', 'move to junk folder'], + ['n', 'mark not junk'], + ['a', 'move to archive folder'], + ['u', 'mark unread'], + ['m', 'mark read'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + + dom.tr(dom.td(attr.colspan('2'), dom.h2('Compose', style({margin: '1ex 0 0 0'})))), + [ + ['ctrl Enter', 'send message'], + ['ctrl w', 'cancel message'], + ['ctlr O', 'add To'], + ['ctrl C', 'add Cc'], + ['ctrl B', 'add Bcc'], + ['ctrl Y', 'add Reply-To'], + ['ctrl -', 'remove current address'], + ['ctrl +', 'add address of same type'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + ), + ), + dom.div( + style({width: '40em'}), + + dom.table( + dom.tr(dom.td(attr.colspan('2'), dom.h2('Message', style({margin: '0'})))), + [ + ['r', 'reply or list reply'], + ['R', 'reply all'], + ['f', 'forward message'], + ['v', 'view attachments'], + ['T', 'view text version'], + ['X', 'view HTML version'], + ['o', 'open message in new tab'], + ['O', 'show raw message'], + ['ctrl p', 'print message'], + ['I', 'toggle internals'], + ['ctrl I', 'toggle all headers'], + + ['alt k, alt ArrowUp', 'scroll up'], + ['alt j, alt ArrowDown', 'scroll down'], + ['alt K', 'scroll to top'], + ['alt J', 'scroll to end'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + + dom.tr(dom.td(dom.h2('Attachments', style({margin: '1ex 0 0 0'})))), + [ + ['left, h', 'previous attachment'], + ['right, l', 'next attachment'], + ['0', 'first attachment'], + ['$', 'next attachment'], + ['d', 'download'], + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), + ), + dom.div(style({marginTop: '2ex', marginBottom: '1ex'}), dom.span('Underdotted text', attr.title('Underdotted text shows additional information on hover.')), ' show an explanation or additional information when hovered.'), + dom.div(style({marginBottom: '1ex'}), 'Multiple messages can be selected by clicking messages while holding the control and/or shift keys. Dragging messages and dropping them on a mailbox moves the messages to that mailbox.'), + dom.div(style({marginBottom: '1ex'}), 'Text that changes ', dom.span(attr.title('Unicode blocks, e.g. from basic latin to cyrillic, or to emoticons.'), '"character groups"'), ' without whitespace has an ', dom.span(dom._class('scriptswitch'), 'orange underline'), ', which can be a sign of an intent to mislead (e.g. phishing).'), + + settings.showShortcuts ? + dom.div(style({marginTop: '2ex'}), 'Shortcut keys for mouse operation are shown in the bottom left. ', + dom.clickbutton('Disable', function click() { + settingsPut({...settings, showShortcuts: false}) + remove() + cmdHelp() + }) + ) : + dom.div(style({marginTop: '2ex'}), 'Shortcut keys for mouse operation are currently not shown. ', + dom.clickbutton('Enable', function click() { + settingsPut({...settings, showShortcuts: true}) + remove() + cmdHelp() + }) + ), + dom.div(style({marginTop: '2ex'}), 'Mox is open source email server software, this is version '+moxversion+'. Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'), '.'), + ), + ), + ) +} + +// Show tooltips for either the focused element, or otherwise for all elements +// that aren't reachable with tabindex and aren't marked specially to prevent +// them from showing up (e.g. dates in the msglistview, which can also been +// seen by opening a message). +const cmdTooltip = async () => { + let elems: Element[] = [] + if (document.activeElement && document.activeElement !== document.body) { + if (document.activeElement.getAttribute('title')) { + elems = [document.activeElement] + } + elems = [...elems, ...document.activeElement.querySelectorAll('[title]')] + } + if (elems.length === 0) { + // Find elements without a parent with tabindex=0. + const seen: {[title: string]: boolean} = {} + elems = [...document.body.querySelectorAll('[title]:not(.notooltip):not(.silenttitle)')].filter(e => { + const title = e.getAttribute('title') || '' + if (seen[title]) { + return false + } + seen[title] = true + return !(e instanceof HTMLInputElement || e instanceof HTMLSelectElement || e instanceof HTMLButtonElement || e instanceof HTMLTextAreaElement || e instanceof HTMLAnchorElement || e.getAttribute('tabindex') || e.closest('[tabindex]')) + }) + } + if (elems.length === 0) { + window.alert('No active elements with tooltips found.') + return + } + popover(document.body, {transparent: true, fullscreen: true}, + ...elems.map(e => { + const title = e.getAttribute('title') || '' + const pos = e.getBoundingClientRect() + return dom.div( + style({position: 'absolute', backgroundColor: 'black', color: 'white', borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em'}), + pos.x < window.innerWidth/3 ? + style({left: ''+(pos.x)+'px'}) : + style({right: ''+(window.innerWidth - pos.x - pos.width)+'px'}), + pos.y+pos.height > window.innerHeight*2/3 ? + style({bottom: ''+(window.innerHeight - (pos.y-2))+'px', maxHeight: ''+(pos.y - 2)+'px'}) : + style({top: ''+(pos.y+pos.height+2)+'px', maxHeight: ''+(window.innerHeight - (pos.y+pos.height+2))+'px'}), + title, + ) + }) + ) +} + +type ComposeOptions = { + from?: api.MessageAddress[] + // Addressees should be either directly an email address, or the header form "name + // ". They are parsed on the server when the message is + // submitted. + to?: string[] + cc?: string[] + bcc?: string[] + replyto?: string + subject?: string + isForward?: boolean + body?: string + // Message from which to show the attachment to include. + attachmentsMessageItem?: api.MessageItem + // Message is marked as replied/answered or forwarded after submitting, and + // In-Reply-To and References headers are added pointing to this message. + responseMessageID?: number +} + +interface ComposeView { + root: HTMLElement + key: (k: string, e: KeyboardEvent) => Promise +} + +let composeView: ComposeView | null = null + +const compose = (opts: ComposeOptions) => { + log('compose', opts) + + if (composeView) { + // todo: should allow multiple + window.alert('Can only compose one message at a time.') + return + } + + type ForwardAttachmentView = { + root: HTMLElement + path: number[] + checkbox: HTMLInputElement + } + + type AddrView = { + root: HTMLElement + input: HTMLInputElement + } + + let fieldset: HTMLFieldSetElement + let from: HTMLSelectElement + let customFrom: HTMLInputElement | null = null + let subject: HTMLInputElement + let body: HTMLTextAreaElement + let attachments: HTMLInputElement + + let toBtn: HTMLButtonElement, ccBtn: HTMLButtonElement, bccBtn: HTMLButtonElement, replyToBtn: HTMLButtonElement, customFromBtn: HTMLButtonElement + let replyToCell: HTMLElement, toCell: HTMLElement, ccCell: HTMLElement, bccCell: HTMLElement // Where we append new address views. + let toRow: HTMLElement, replyToRow: HTMLElement, ccRow: HTMLElement, bccRow: HTMLElement // We show/hide rows as needed. + let toViews: AddrView[] = [], replytoViews: AddrView[] = [], ccViews: AddrView[] = [], bccViews: AddrView[] = [] + let forwardAttachmentViews: ForwardAttachmentView[] = [] + + const cmdCancel = async () => { + composeElem.remove() + composeView = null + } + + const submit = async () => { + const files = await new Promise((resolve, reject) => { + const l: api.File[] = [] + if (attachments.files && attachments.files.length === 0) { + resolve(l) + return + } + [...attachments.files!].forEach(f => { + const fr = new window.FileReader() + fr.addEventListener('load', () => { + l.push({Filename: f.name, DataURI: fr.result as string}) + if (attachments.files && l.length == attachments.files.length) { + resolve(l) + } + }) + fr.addEventListener('error', () => { + reject(fr.error) + }) + fr.readAsDataURL(f) + }) + }) + + let replyTo = '' + if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) { + replyTo = replytoViews[0].input.value + } + + const forwardAttachmentPaths = forwardAttachmentViews.filter(v => v.checkbox.checked).map(v => v.path) + + const message = { + From: customFrom ? customFrom.value : from.value, + To: toViews.map(v => v.input.value).filter(s => s), + Cc: ccViews.map(v => v.input.value).filter(s => s), + Bcc: bccViews.map(v => v.input.value).filter(s => s), + ReplyTo: replyTo, + UserAgent: 'moxwebmail/'+moxversion, + Subject: subject.value, + TextBody: body.value, + Attachments: files, + ForwardAttachments: forwardAttachmentPaths.length === 0 ? {MessageID: 0, Paths: []} : {MessageID: opts.attachmentsMessageItem!.Message.ID, Paths: forwardAttachmentPaths}, + IsForward: opts.isForward || false, + ResponseMessageID: opts.responseMessageID || 0, + } + await client.MessageSubmit(message) + cmdCancel() + } + + const cmdSend = async () => { + await withStatus('Sending email', submit(), fieldset) + } + + const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow) } + const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow) } + const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow) } + const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true) } + const cmdCustomFrom = async () => { + if (customFrom) { + return + } + customFrom = dom.input(attr.value(from.value), attr.required(''), focusPlaceholder('Jane ')) + from.replaceWith(customFrom) + customFromBtn.remove() + } + + const shortcuts: {[key: string]: command} = { + 'ctrl Enter': cmdSend, + 'ctrl w': cmdCancel, + 'ctrl O': cmdAddTo, + 'ctrl C': cmdAddCc, + 'ctrl B': cmdAddBcc, + 'ctrl Y': cmdReplyTo, + // ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add. + } + + const newAddrView = (addr: string, views: AddrView[], btn: HTMLButtonElement, cell: HTMLElement, row: HTMLElement, single?: boolean) => { + if (single && views.length !== 0) { + return + } + + let input: HTMLInputElement + const root = dom.span( + input=dom.input( + focusPlaceholder('Jane '), + style({width: 'auto'}), + attr.value(addr), + newAddressComplete(), + function keydown(e: KeyboardEvent) { + if (e.key === '-' && e.ctrlKey) { + remove() + } else if (e.key === '=' && e.ctrlKey) { + newAddrView('', views, btn, cell, row, single) + } else { + return + } + e.preventDefault() + e.stopPropagation() + }, + ), + ' ', + dom.clickbutton('-', style({padding: '0 .25em'}), attr.arialabel('Remove address.'), attr.title('Remove address.'), function click() { + remove() + if (single && views.length === 0) { + btn.style.display = '' + } + }), + ' ', + ) + + const remove = () => { + const i = views.indexOf(v) + views.splice(i, 1) + root.remove() + if (views.length === 0) { + row.style.display = 'none' + } + if (views.length === 0 && single) { + btn.style.display = '' + } + + let next = cell.querySelector('input') + if (!next) { + let tr = row!.nextSibling as Element + while (tr) { + next = tr.querySelector('input') + if (!next && tr.nextSibling) { + tr = tr.nextSibling as Element + continue + } + break + } + } + if (next) { + next.focus() + } + } + + const v: AddrView = {root: root, input: input} + views.push(v) + cell.appendChild(v.root) + row.style.display = '' + if (single) { + btn.style.display = 'none' + } + input.focus() + return v + } + + let noAttachmentsWarning: HTMLElement + const checkAttachments = () => { + const missingAttachments = !attachments.files?.length && !forwardAttachmentViews.find(v => v.checkbox.checked) && !!body.value.split('\n').find(s => !s.startsWith('>') && s.match(/attach(ed|ment)/)) + noAttachmentsWarning.style.display = missingAttachments ? '' : 'none' + } + + const normalizeUser = (a: api.MessageAddress) => { + let user = a.User + const domconf = domainAddressConfigs[a.Domain.ASCII] + const localpartCatchallSeparator = domconf.LocalpartCatchallSeparator + if (localpartCatchallSeparator) { + user = user.split(localpartCatchallSeparator)[0] + } + const localpartCaseSensitive = domconf.LocalpartCaseSensitive + if (!localpartCaseSensitive) { + user = user.toLowerCase() + } + return user + } + // Find own address matching the specified address, taking wildcards, localpart + // separators and case-sensitivity into account. + const addressSelf = (addr: api.MessageAddress) => { + return accountAddresses.find(a => a.Domain.ASCII === addr.Domain.ASCII && (a.User === '' || normalizeUser(a) == normalizeUser(addr))) + } + + let haveFrom = false + const fromOptions = accountAddresses.map(a => { + const selected = opts.from && opts.from.length === 1 && equalAddress(a, opts.from[0]) || loginAddress && equalAddress(a, loginAddress) && (!opts.from || envelopeIdentity(opts.from)) + log('fromOptions', a, selected, loginAddress, equalAddress(a, loginAddress!)) + const o = dom.option(formatAddressFull(a), selected ? attr.selected('') : []) + if (selected) { + haveFrom = true + } + return o + }) + if (!haveFrom && opts.from && opts.from.length === 1) { + const a = addressSelf(opts.from[0]) + if (a) { + const fromAddr: api.MessageAddress = {Name: a.Name, User: opts.from[0].User, Domain: a.Domain} + const o = dom.option(formatAddressFull(fromAddr), attr.selected('')) + fromOptions.unshift(o) + } + } + + const composeElem = dom.div( + style({ + position: 'fixed', + bottom: '1ex', + right: '1ex', + zIndex: zindexes.compose, + backgroundColor: 'white', + boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', + border: '1px solid #ccc', + padding: '1em', + minWidth: '40em', + maxWidth: '70em', + width: '40%', + borderRadius: '.25em', + }), + dom.form( + fieldset=dom.fieldset( + dom.table( + style({width: '100%'}), + dom.tr( + dom.td( + style({textAlign: 'right', color: '#555'}), + dom.span('From:'), + ), + dom.td( + dom.clickbutton('Cancel', style({float: 'right'}), attr.title('Close window, discarding message.'), clickCmd(cmdCancel, shortcuts)), + from=dom.select( + attr.required(''), + style({width: 'auto'}), + fromOptions, + ), + ' ', + toBtn=dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', + ccBtn=dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', + bccBtn=dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', + replyToBtn=dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', + customFromBtn=dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts)), + ), + ), + toRow=dom.tr( + dom.td('To:', style({textAlign: 'right', color: '#555'})), + toCell=dom.td(style({width: '100%'})), + ), + replyToRow=dom.tr( + dom.td('Reply-To:', style({textAlign: 'right', color: '#555'})), + replyToCell=dom.td(style({width: '100%'})), + ), + ccRow=dom.tr( + dom.td('Cc:', style({textAlign: 'right', color: '#555'})), + ccCell=dom.td(style({width: '100%'})), + ), + bccRow=dom.tr( + dom.td('Bcc:', style({textAlign: 'right', color: '#555'})), + bccCell=dom.td(style({width: '100%'})), + ), + dom.tr( + dom.td('Subject:', style({textAlign: 'right', color: '#555'})), + dom.td(style({width: '100%'}), + subject=dom.input(focusPlaceholder('subject...'), attr.value(opts.subject || ''), attr.required(''), style({width: '100%'})), + ), + ), + ), + body=dom.textarea(dom._class('mono'), attr.rows('15'), style({width: '100%'}), + opts.body || '', + opts.body && !opts.isForward ? prop({selectionStart: opts.body.length, selectionEnd: opts.body.length}) : [], + function keyup(e: KeyboardEvent) { + if (e.key === 'Enter') { + checkAttachments() + } + }, + ), + !(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div( + style({margin: '.5em 0'}), + 'Forward attachments: ', + forwardAttachmentViews=(opts.attachmentsMessageItem?.Attachments || []).map(a => { + const filename = a.Filename || '(unnamed)' + const size = formatSize(a.Part.DecodedSize) + const checkbox = dom.input(attr.type('checkbox'), function change() { checkAttachments() }) + const root = dom.label(checkbox, ' '+filename+' ', dom.span('('+size+') ', style({color: '#666'}))) + const v: ForwardAttachmentView = { + path: a.Path || [], + root: root, + checkbox: checkbox + } + return v + }), + dom.label(style({color: '#666'}), dom.input(attr.type('checkbox'), function change(e: Event) { + forwardAttachmentViews.forEach(v => v.checkbox.checked = (e.target! as HTMLInputElement).checked) + }), ' (Toggle all)') + ), + noAttachmentsWarning=dom.div(style({display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0'}), 'Message mentions attachments, but no files are attached.'), + dom.div(style({margin: '1ex 0'}), 'Attachments ', attachments=dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments() })), + dom.submitbutton('Send'), + ), + async function submit(e: SubmitEvent) { + e.preventDefault() + shortcutCmd(cmdSend, shortcuts) + }, + ), + ) + + ;(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)) + ;(opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)) + ;(opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)) + if (opts.replyto) { + newAddrView(opts.replyto, replytoViews, replyToBtn, replyToCell, replyToRow, true) + } + if (!opts.cc || !opts.cc.length) { + ccRow.style.display = 'none' + } + if (!opts.bcc || !opts.bcc.length) { + bccRow.style.display = 'none' + } + if (!opts.replyto) { + replyToRow.style.display = 'none' + } + + document.body.appendChild(composeElem) + if (toViews.length > 0 && !toViews[0].input.value) { + toViews[0].input.focus() + } else { + body.focus() + } + + composeView = { + root: composeElem, + key: keyHandler(shortcuts), + } + return composeView +} + +// Show popover to edit labels for msgs. +const labelsPopover = (e: MouseEvent, msgs: api.Message[], possibleLabels: possibleLabels): void => { + if (msgs.length === 0) { + return // Should not happen. + } + + const knownLabels = possibleLabels() + const activeLabels = (msgs[0].Keywords || []).filter(kw => msgs.filter(m => (m.Keywords || []).includes(kw)).length === msgs.length) + const msgIDs = msgs.map(m => m.ID) + let fieldsetnew: HTMLFieldSetElement + let newlabel: HTMLInputElement + + const remove = popover(e.target! as HTMLElement, {}, + dom.div( + style({display: 'flex', flexDirection: 'column', gap: '1ex'}), + knownLabels.map(l => + dom.div( + dom.label( + dom.input( + attr.type('checkbox'), + activeLabels.includes(l) ? attr.checked('') : [], style({marginRight: '.5em'}), + attr.title('Add/remove this label to the message(s), leaving other labels unchanged.'), + async function change(e: MouseEvent) { + if (activeLabels.includes(l)) { + await withStatus('Removing label', client.FlagsClear(msgIDs, [l]), e.target! as HTMLInputElement) + activeLabels.splice(activeLabels.indexOf(l), 1) + } else { + await withStatus('Adding label', client.FlagsAdd(msgIDs, [l]), e.target! as HTMLInputElement) + activeLabels.push(l) + } + }, + ), + ' ', + dom.span(dom._class('keyword'), l), + ), + ) + ), + ), + dom.hr(style({margin: '2ex 0'})), + dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + await withStatus('Adding new label', client.FlagsAdd(msgIDs, [newlabel.value]), fieldsetnew) + remove() + }, + fieldsetnew=dom.fieldset( + dom.div( + newlabel=dom.input(focusPlaceholder('new-label'), attr.required(''), attr.title('New label to add/set on the message(s), must be lower-case, ascii-only, without spaces and without the following special characters: (){%*"\].')), + ' ', + dom.submitbutton('Add new label', attr.title('Add this label to the message(s), leaving other labels unchanged.')), + ), + ), + ), + ) +} + +// Show popover to move messages to a mailbox. +const movePopover = (e: MouseEvent, mailboxes: api.Mailbox[], msgs: api.Message[]) => { + if (msgs.length === 0) { + return // Should not happen. + } + let msgsMailboxID = (msgs[0].MailboxID && msgs.filter(m => m.MailboxID === msgs[0].MailboxID).length === msgs.length) ? msgs[0].MailboxID : 0 + + const remove = popover(e.target! as HTMLElement, {}, + dom.div( + style({display: 'flex', flexDirection: 'column', gap: '.25em'}), + mailboxes.map(mb => + dom.div( + dom.clickbutton( + mb.Name, + mb.ID === msgsMailboxID ? attr.disabled('') : [], + async function click() { + const msgIDs = msgs.filter(m => m.MailboxID !== mb.ID).map(m => m.ID) + await withStatus('Moving to mailbox', client.MessageMove(msgIDs, mb.ID)) + remove() + } + ), + ) + ), + ) + ) +} + +// MsgitemView is a message-line in the list of messages. Selecting it loads and displays the message, a MsgView. +interface MsgitemView { + root: HTMLElement // MsglistView toggles active/focus classes on the root element. + messageitem: api.MessageItem + // Called when flags/keywords change for a message. + updateFlags: (mask: api.Flags, flags: api.Flags, keywords: string[]) => void + + // Must be called when MsgitemView is no longer needed. Typically through + // msglistView.clear(). This cleans up the timer that updates the message age. + remove: () => void +} + +// Make new MsgitemView, to be added to the list. othermb is set when this msgitem +// is displayed in a msglistView for other/multiple mailboxes, the mailbox name +// should be shown. +const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, othermb: api.Mailbox | null): MsgitemView => { + // Timer to update the age of the message. + let ageTimer = 0 + + // Show with a tag if we are in the cc/bcc headers, or - if none. + const identityTag = (s: string, title: string) => dom.span(dom._class('msgitemidentity'), s, attr.title(title)) + const identityHeader: HTMLElement[] = [] + if (!envelopeIdentity(mi.Envelope.From || []) && !envelopeIdentity(mi.Envelope.To || [])) { + if (envelopeIdentity(mi.Envelope.CC || [])) { + identityHeader.push(identityTag('cc', 'You are in the CC header')) + } + if (envelopeIdentity(mi.Envelope.BCC || [])) { + identityHeader.push(identityTag('bcc', 'You are in the BCC header')) + } + // todo: don't include this if this is a message to a mailling list, based on list-* headers. + if (identityHeader.length === 0) { + identityHeader.push(identityTag('-', 'You are not in any To, From, CC, BCC header. Could message to a mailing list or Bcc without Bcc message header.')) + } + } + + // If mailbox of message is not specified in filter (i.e. for mailbox list or + // search on the mailbox), we show it on the right-side of the subject. + const mailboxtag: HTMLElement[] = [] + if (othermb) { + let name = othermb.Name + if (name.length > 8+1+3+1+8+4) { + const t = name.split('/') + const first = t[0] + const last = t[t.length-1] + if (first.length + last.length <= 8+8) { + name = first+'/.../'+last + } else { + name = first.substring(0, 8) + '/.../' + last.substring(0, 8) + } + } + const e = dom.span(dom._class('msgitemmailbox'), + name === othermb.Name ? [] : attr.title(othermb.Name), + name, + ) + mailboxtag.push(e) + } + + const updateFlags = (mask: api.Flags, flags: api.Flags, keywords: string[]) => { + const maskobj = mask as unknown as {[key: string]: boolean} + const flagsobj = flags as unknown as {[key: string]: boolean} + const mobj = msgitemView.messageitem.Message as unknown as {[key: string]: boolean} + for (const k in maskobj) { + if (maskobj[k]) { + mobj[k] = flagsobj[k] + } + } + msgitemView.messageitem.Message.Keywords = keywords + const elem = render() + msgitemView.root.replaceWith(elem) + msgitemView.root = elem + msglistView.redraw(msgitemView) + } + + const remove = (): void => { + msgitemView.root.remove() + if (ageTimer) { + window.clearTimeout(ageTimer) + ageTimer = 0 + } + } + + const age = (date: Date): HTMLElement => { + const r = dom.span(dom._class('notooltip'), attr.title(date.toString())) + + const set = () => { + const nowSecs = new Date().getTime()/1000 + let t = nowSecs - date.getTime()/1000 + let negative = '' + if (t < 0) { + negative = '-' + 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] + const suffix = ['y', 'mo', 'd', 'h', 'min'] + let s + let nextSecs = 0 + for (let i = 0; i < periods.length; i++) { + const p = periods[i] + if (t >= 2*p || i == periods.length-1) { + const n = Math.round(t/p) + s = '' + n + suffix[i] + const prev = Math.floor(t/p) + nextSecs = Math.ceil((prev+1)*p - t) + break + } + } + if (t < 60) { + s = '<1min' + nextSecs = 60-t + } + + dom._kids(r, negative+s) + // note: Cannot have delays longer than 24.8 days due to storage as 32 bit in + // browsers. Session is likely closed/reloaded/refreshed before that time anyway. + if (nextSecs < 14*24*3600) { + ageTimer = window.setTimeout(set, nextSecs*1000) + } else { + ageTimer = 0 + } + } + + set() + return r + } + + const render = () => { + // Set by calling age(). + if (ageTimer) { + window.clearTimeout(ageTimer) + ageTimer = 0 + } + + const m = msgitemView.messageitem.Message + const keywords = (m.Keywords || []).map(kw => dom.span(dom._class('keyword'), kw)) + + return dom.div(dom._class('msgitem'), + attr.draggable('true'), + function dragstart(e: DragEvent) { + e.dataTransfer!.setData('application/vnd.mox.messages', JSON.stringify(msglistView.selected().map(miv => miv.messageitem.Message.ID))) + }, + m.Seen ? [] : style({fontWeight: 'bold'}), + dom.div(dom._class('msgitemcell', 'msgitemflags'), flagList(m, msgitemView.messageitem)), + dom.div(dom._class('msgitemcell', 'msgitemfrom'), + dom.div(style({display: 'flex', justifyContent: 'space-between'}), + dom.div(dom._class('msgitemfromtext', 'silenttitle'), + attr.title((mi.Envelope.From || []).map(a => formatAddressFull(a)).join(', ')), + join((mi.Envelope.From || []).map(a => formatAddressShort(a)), () => ', ') + ), + identityHeader, + ), + ), + dom.div(dom._class('msgitemcell', 'msgitemsubject'), + dom.div(style({display: 'flex', justifyContent: 'space-between', position: 'relative'}), + dom.div(dom._class('msgitemsubjecttext'), + mi.Envelope.Subject || '(no subject)', + dom.span(dom._class('msgitemsubjectsnippet'), ' '+mi.FirstLine), + ), + dom.div( + keywords, + mailboxtag, + ), + ), + ), + dom.div(dom._class('msgitemcell', 'msgitemage'), age(m.Received)), + function click(e: MouseEvent) { + e.preventDefault() + e.stopPropagation() + msglistView.click(msgitemView, e.ctrlKey, e.shiftKey) + } + ) + } + + const msgitemView: MsgitemView = { + root: dom.div(), + messageitem: mi, + updateFlags: updateFlags, + remove: remove, + } + msgitemView.root = render() + + return msgitemView +} + +interface MsgView { + root: HTMLElement + messageitem: api.MessageItem + // Called when keywords for a message have changed, to rerender them. + updateKeywords: (keywords: string[]) => void + // Abort loading the message. + aborter: { abort: () => void } + key: (key: string, e: KeyboardEvent) => Promise +} + +// If attachmentView is open, keyboard shortcuts go there. +let attachmentView: {key: (k: string, e: KeyboardEvent) => Promise} | null = null + +// MsgView is the display of a single message. +// refineKeyword is called when a user clicks a label, to filter on those. +const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: listMailboxes, possibleLabels: possibleLabels, messageLoaded: () => void, refineKeyword: (kw: string) => Promise, parsedMessageOpt?: api.ParsedMessage): MsgView => { + const mi = miv.messageitem + const m = mi.Message + + const formatEmailAddress = (a: api.MessageAddress) => a.User + '@' + a.Domain.ASCII + const fromAddress = mi.Envelope.From && mi.Envelope.From.length === 1 ? formatEmailAddress(mi.Envelope.From[0]) : '' + + // Some operations below, including those that can be reached through shortcuts, + // need a parsed message. So we keep a promise around for having that parsed + // message. Operations always await it. Once we have the parsed message, the await + // completes immediately. + // Typescript doesn't know the function passed to new Promise runs immediately and + // has set the Resolve and Reject variables before returning. Is there a better + // solution? + let parsedMessageResolve: (pm: api.ParsedMessage) => void = () => {} + let parsedMessageReject: (err: Error) => void = () => {} + let parsedMessagePromise = new Promise((resolve, reject) => { + parsedMessageResolve = resolve + parsedMessageReject = reject + }) + + const react = async (to: api.MessageAddress[] | null, forward: boolean, all: boolean) => { + const pm = await parsedMessagePromise + let body = '' + const sel = window.getSelection() + if (sel && sel.toString()) { + body = sel.toString() + } else if (pm.Texts && pm.Texts.length > 0) { + body = pm.Texts[0] + } + body = body.replace(/\r/g, '').replace(/\n\n\n\n*/g, '\n\n').trim() + if (forward) { + body = '\n\n---- Forwarded Message ----\n\n'+body + } else { + body = body.split('\n').map(line => '> ' + line).join('\n') + '\n\n' + } + const subjectPrefix = forward ? 'Fwd:' : 'Re:' + let subject = mi.Envelope.Subject || '' + subject = (RegExp('^'+subjectPrefix, 'i').test(subject) ? '' : subjectPrefix+' ') + subject + const opts: ComposeOptions = { + from: mi.Envelope.To || undefined, + to: (to || []).map(a => formatAddress(a)), + cc: [], + bcc: [], + subject: subject, + body: body, + isForward: forward, + attachmentsMessageItem: forward ? mi : undefined, + responseMessageID: m.ID, + } + if (all) { + opts.to = (to || []).concat((mi.Envelope.To || []).filter(a => !envelopeIdentity([a]))).map(a => formatAddress(a)) + opts.cc = (mi.Envelope.CC || []).map(a => formatAddress(a)) + opts.bcc = (mi.Envelope.BCC || []).map(a => formatAddress(a)) + } + compose(opts) + } + + const reply = async (all: boolean, toOpt?: api.MessageAddress[]) => { + await react(toOpt || ((mi.Envelope.ReplyTo || []).length > 0 ? mi.Envelope.ReplyTo : mi.Envelope.From) || null, false, all) + } + const cmdForward = async () => { react([], true, false) } + const cmdReplyList = async () => { + const pm = await parsedMessagePromise + if (pm.ListReplyAddress) { + await reply(false, [pm.ListReplyAddress]) + } + } + const cmdReply = async () => { await reply(false) } + const cmdReplyAll = async () => { await reply(true) } + const cmdPrint = async () => { + if (urlType) { + window.open('msg/'+m.ID+'/msg'+urlType+'#print', '_blank') + } + } + const cmdOpenNewTab = async () => { + if (urlType) { + window.open('msg/'+m.ID+'/msg'+urlType, '_blank') + } + } + const cmdOpenRaw = async () => { window.open('msg/'+m.ID+'/raw', '_blank') } + const cmdViewAttachments = async () => { + if (attachments.length > 0) { + view(attachments[0]) + } + } + + const cmdToggleHeaders = async () => { + settingsPut({...settings, showAllHeaders: !settings.showAllHeaders}) + loadHeaderDetails(await parsedMessagePromise) + } + + let textbtn: HTMLButtonElement, htmlbtn: HTMLButtonElement, htmlextbtn: HTMLButtonElement + const activeBtn = (b: HTMLButtonElement) => { + for (const xb of [textbtn, htmlbtn, htmlextbtn]) { + xb.classList.toggle('active', xb === b) + } + } + + const cmdShowText = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return + } + loadText(await parsedMessagePromise) + settingsPut({...settings, showHTML: false}) + activeBtn(textbtn) + } + const cmdShowHTML = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return + } + loadHTML() + settingsPut({...settings, showHTML: true}) + activeBtn(htmlbtn) + } + const cmdShowHTMLExternal = async () => { + if (!textbtn || !htmlbtn || !htmlextbtn) { + return + } + loadHTMLexternal() + settingsPut({...settings, showHTML: true}) + activeBtn(htmlextbtn) + } + const cmdShowHTMLCycle = async () => { + if (urlType === 'html') { + await cmdShowHTMLExternal() + } else { + await cmdShowHTML() + } + } + const cmdShowInternals = async () => { + const pm = await parsedMessagePromise + const mimepart = (p: api.Part): HTMLElement => dom.li( + (p.MediaType + '/' + p.MediaSubType).toLowerCase(), + p.ContentTypeParams ? ' '+JSON.stringify(p.ContentTypeParams) : [], + p.Parts && p.Parts.length === 0 ? [] : dom.ul( + style({listStyle: 'disc', marginLeft: '1em'}), + (p.Parts || []).map(pp => mimepart(pp)) + ) + ) + popup( + style({display: 'flex', gap: '1em'}), + dom.div(dom.h1('Mime structure'), dom.ul(style({listStyle: 'disc', marginLeft: '1em'}), mimepart(pm.Part))), + dom.div(style({whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), + dom.div(style({whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t')), + ) + } + + const cmdUp = async () => { msgscrollElem.scrollTo({top: msgscrollElem.scrollTop - 3*msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth'}) } + const cmdDown = async () => { msgscrollElem.scrollTo({top: msgscrollElem.scrollTop + 3*msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth'}) } + const cmdHome = async () => { msgscrollElem.scrollTo({top: 0 }) } + const cmdEnd = async () => { msgscrollElem.scrollTo({top: msgscrollElem.scrollHeight}) } + + const shortcuts: {[key: string]: command} = { + I: cmdShowInternals, + o: cmdOpenNewTab, + O: cmdOpenRaw, + 'ctrl p': cmdPrint, + f: cmdForward, + r: cmdReply, + R: cmdReplyAll, + v: cmdViewAttachments, + T: cmdShowText, + X: cmdShowHTMLCycle, + 'ctrl I': cmdToggleHeaders, + + 'alt j': cmdDown, + 'alt k': cmdUp, + 'alt ArrowDown': cmdDown, + 'alt ArrowUp': cmdUp, + 'alt J': cmdEnd, + 'alt K': cmdHome, + + // For showing shortcuts only, handled in msglistView. + a: msglistView.cmdArchive, + d: msglistView.cmdTrash, + D: msglistView.cmdDelete, + q: msglistView.cmdJunk, + n: msglistView.cmdMarkNotJunk, + u: msglistView.cmdMarkUnread, + m: msglistView.cmdMarkRead, + } + + let urlType: string // text, html, htmlexternal; for opening in new tab/print + + let msgbuttonElem: HTMLElement, msgheaderElem: HTMLElement, msgattachmentElem: HTMLElement, msgmodeElem: HTMLElement + let msgheaderdetailsElem: HTMLElement | null = null // When full headers are visible, or some headers are requested through settings. + + const msgmetaElem = dom.div( + style({backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', maxHeight: '90%', overflowY: 'auto'}), + attr.role('region'), attr.arialabel('Buttons and headers for message'), + msgbuttonElem=dom.div(), + dom.div( + attr.arialive('assertive'), + msgheaderElem=dom.table(style({marginBottom: '1ex', width: '100%'})), + msgattachmentElem=dom.div(), + msgmodeElem=dom.div(), + ), + ) + + const msgscrollElem = dom.div(dom._class('pad', 'yscrollauto'), + attr.role('region'), attr.arialabel('Message body'), + style({backgroundColor: 'white'}), + ) + const msgcontentElem = dom.div(dom._class('scrollparent'), + style({flexGrow: '1'}), + ) + + const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID + + // Initially called with potentially null pm, once loaded called again with pm set. + const loadButtons = (pm: api.ParsedMessage | null) => { + dom._kids(msgbuttonElem, + dom.div(dom._class('pad'), + (!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ', + (pm && pm.ListReplyAddress && formatEmailAddress(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ', + (mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] : + dom.clickbutton('Reply all', attr.title('Compose a reply to all participants of this message.'), clickCmd(cmdReplyAll, shortcuts)), ' ', + dom.clickbutton('Forward', attr.title('Compose a forwarding message, optionally including attachments.'), clickCmd(cmdForward, shortcuts)), ' ', + dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(msglistView.cmdArchive, shortcuts)), ' ', + m.MailboxID === trashMailboxID ? + dom.clickbutton('Delete', attr.title('Permanently delete message.'), clickCmd(msglistView.cmdDelete, shortcuts)) : + dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(msglistView.cmdTrash, shortcuts)), + ' ', + dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdJunk, shortcuts)), ' ', + dom.clickbutton('Move to...', function click(e: MouseEvent) { + movePopover(e, listMailboxes(), [m]) + }), ' ', + dom.clickbutton('Labels...', attr.title('Add/remove labels.'), function click(e: MouseEvent) { + labelsPopover(e, [m], possibleLabels) + }), ' ', + dom.clickbutton('More...', attr.title('Show more actions.'), function click(e: MouseEvent) { + popover(e.target! as HTMLElement, {transparent: true}, + dom.div( + style({display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right'}), + [ + dom.clickbutton('Print', attr.title('Print message, opens in new tab and opens print dialog.'), clickCmd(cmdPrint, shortcuts)), + dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdMarkNotJunk, shortcuts)), + dom.clickbutton('Mark as read', clickCmd(msglistView.cmdMarkRead, shortcuts)), + dom.clickbutton('Mark as unread', clickCmd(msglistView.cmdMarkUnread, shortcuts)), + dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)), + dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)), + dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)), + ].map(b => dom.div(b)), + ), + ) + }), + ) + ) + } + loadButtons(parsedMessageOpt || null) + + loadMsgheaderView(msgheaderElem, miv.messageitem, refineKeyword) + + const loadHeaderDetails = (pm: api.ParsedMessage) => { + if (msgheaderdetailsElem) { + msgheaderdetailsElem.remove() + msgheaderdetailsElem = null + } + if (!settings.showAllHeaders) { + return + } + msgheaderdetailsElem = dom.table( + style({marginBottom: '1ex', width: '100%'}), + Object.entries(pm.Headers || {}).sort().map(t => + (t[1] || []).map(v => + dom.tr( + dom.td(t[0]+':', style({textAlign: 'right', color: '#555'})), + dom.td(v), + ) + ) + ) + ) + msgattachmentElem.parentNode!.insertBefore(msgheaderdetailsElem, msgattachmentElem) + } + + // From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types + const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', + ] + const isImage = (a: api.Attachment) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()) + const isPDF = (a: api.Attachment) => (a.Part.MediaType+'/'+a.Part.MediaSubType).toLowerCase() === 'application/pdf' + const isViewable = (a: api.Attachment) => isImage(a) || isPDF(a) + const attachments: api.Attachment[] = (mi.Attachments || []) + + let beforeViewFocus: Element | null + const view = (a: api.Attachment) => { + if (!beforeViewFocus) { + beforeViewFocus = document.activeElement + } + + const pathStr = [0].concat(a.Path || []).join('.') + const index = attachments.indexOf(a) + + const cmdViewPrev = async () => { + if (index > 0) { + popupRoot.remove() + view(attachments[index-1]) + } + } + const cmdViewNext = async () => { + if (index < attachments.length-1) { + popupRoot.remove() + view(attachments[index+1]) + } + } + const cmdViewFirst = async () => { + popupRoot.remove() + view(attachments[0]) + } + const cmdViewLast = async () => { + popupRoot.remove() + view(attachments[attachments.length-1]) + } + const cmdViewClose = async () => { + popupRoot.remove() + if (beforeViewFocus && beforeViewFocus instanceof HTMLElement && beforeViewFocus.parentNode) { + beforeViewFocus.focus() + } + attachmentView = null + beforeViewFocus = null + } + + const attachShortcuts = { + h: cmdViewPrev, + ArrowLeft: cmdViewPrev, + l: cmdViewNext, + ArrowRight: cmdViewNext, + '0': cmdViewFirst, + '$': cmdViewLast, + Escape: cmdViewClose, + } + + let content: HTMLElement + const popupRoot = dom.div( + style({position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments}), + function click(e: MouseEvent) { + e.stopPropagation() + cmdViewClose() + }, + attr.tabindex('0'), + !(index > 0) ? [] : dom.div( + style({position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), + dom.div(dom._class('silenttitle'), + style({backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold'}), + attr.title('To previous viewable attachment.'), + '←', + ), + attr.tabindex('0'), + clickCmd(cmdViewPrev, attachShortcuts), + enterCmd(cmdViewPrev, attachShortcuts), + ), + dom.div( + style({textAlign: 'center', paddingBottom: '30px'}), + dom.span(dom._class('pad'), + function click(e: MouseEvent) { + e.stopPropagation() + }, + style({backgroundColor: 'white', borderRadius: '.25em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd'}), + a.Filename || '(unnamed)', ' - ', + formatSize(a.Part.DecodedSize), ' - ', + dom.a('Download', attr.download(''), attr.href('msg/'+m.ID+'/download/'+pathStr), function click(e: MouseEvent) { e.stopPropagation() }), + ), + ), + isImage(a) ? + dom.div( + style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 5em'}), + dom.img( + attr.src('msg/'+m.ID+'/view/'+pathStr), + style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', margin: '0 30px'}) + ), + ) : ( + isPDF(a) ? + dom.iframe( + style({flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em'}), + attr.title('Attachment as PDF.'), + attr.src('msg/'+m.ID+'/view/'+pathStr) + ) : + content=dom.div( + function click(e: MouseEvent) { + e.stopPropagation() + }, + style({minWidth: '30em', padding: '2ex', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em', textAlign: 'center'}), + dom.div(style({marginBottom: '2ex'}), 'Attachment could be a binary file.'), + dom.clickbutton('View as text', function click() { + content.replaceWith( + dom.iframe( + attr.title('Attachment shown as text, though it could be a binary file.'), + style({flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em'}), + attr.src('msg/'+m.ID+'/viewtext/'+pathStr) + ) + ) + }), + ) + ), + !(index < attachments.length-1) ? [] : dom.div( + style({position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), + dom.div(dom._class('silenttitle'), + style({backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold'}), + attr.title('To next viewable attachment.'), + '→', + ), + attr.tabindex('0'), + clickCmd(cmdViewNext, attachShortcuts), + enterCmd(cmdViewNext, attachShortcuts), + ), + ) + document.body.appendChild(popupRoot) + popupRoot.focus() + attachmentView = {key: keyHandler(attachShortcuts)} + } + + dom._kids(msgattachmentElem, + (mi.Attachments && mi.Attachments.length === 0) ? [] : dom.div( + style({borderTop: '1px solid #ccc'}), + dom.div(dom._class('pad'), + 'Attachments: ', + (mi.Attachments || []).map(a => { + const name = a.Filename || '(unnamed)' + const viewable = isViewable(a) + const size = formatSize(a.Part.DecodedSize) + const eye = '👁' + const dl = '⤓' // \u2913, actually ⭳ \u2b73 would be better, but in fewer fonts (at least macos) + const dlurl = 'msg/'+m.ID+'/download/'+[0].concat(a.Path || []).join('.') + const viewbtn = dom.clickbutton(eye, viewable ? ' '+name : [], attr.title('View this file. Size: '+size), style({lineHeight: '1.5'}), function click() { + view(a) + }) + const dlbtn = dom.a(dom._class('button'), attr.download(''), attr.href(dlurl), dl, viewable ? [] : ' '+name, attr.title('Download this file. Size: '+size), style({lineHeight: '1.5'})) + if (viewable) { + return [dom.span(dom._class('btngroup'), viewbtn, dlbtn), ' '] + } + return [dom.span(dom._class('btngroup'), dlbtn, viewbtn), ' '] + }), + dom.a('Download all as zip', attr.download(''), style({color: 'inherit'}), attr.href('msg/'+m.ID+'/attachments.zip')), + ), + ) + ) + + const root = dom.div(style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column'})) + dom._kids(root, msgmetaElem, msgcontentElem) + + const loadText = (pm: api.ParsedMessage): void => { + // We render text ourselves so we can make links clickable and get any selected + // text to use when writing a reply. We still set url so the text content can be + // opened in a separate tab, even though it will look differently. + urlType = 'text' + const elem = dom.div(dom._class('mono'), + style({whiteSpace: 'pre-wrap'}), + join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({margin: '2ex 0'}))), + ) + dom._kids(msgcontentElem) + dom._kids(msgscrollElem, elem) + dom._kids(msgcontentElem, msgscrollElem) + } + const loadHTML = (): void => { + urlType = 'html' + dom._kids(msgcontentElem, + dom.iframe( + attr.tabindex('0'), + attr.title('HTML version of message with images inlined, without external resources loaded.'), + attr.src('msg/'+m.ID+'/'+urlType), + style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), + ) + ) + } + const loadHTMLexternal = (): void => { + urlType = 'htmlexternal' + dom._kids(msgcontentElem, + dom.iframe( + attr.tabindex('0'), + attr.title('HTML version of message with images inlined and with external resources loaded.'), + attr.src('msg/'+m.ID+'/'+urlType), + style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), + ) + ) + } + + const mv: MsgView = { + root: root, + messageitem: mi, + key: keyHandler(shortcuts), + aborter: { abort: () => {} }, + updateKeywords: (keywords: string[]) => { + mi.Message.Keywords = keywords + loadMsgheaderView(msgheaderElem, miv.messageitem, refineKeyword) + }, + } + + ;(async () => { + let pm: api.ParsedMessage + if (parsedMessageOpt) { + pm = parsedMessageOpt + parsedMessageResolve(pm) + } else { + const promise = withStatus('Loading message', client.withOptions({aborter: mv.aborter}).ParsedMessage(m.ID)) + try { + pm = await promise + } catch (err) { + if (err instanceof Error) { + parsedMessageReject(err) + } else { + parsedMessageReject(new Error('fetching message failed')) + } + throw err + } + parsedMessageResolve(pm) + } + + loadButtons(pm) + loadHeaderDetails(pm) + if (settings.showHeaders.length > 0) { + settings.showHeaders.forEach(k => { + const vl = pm.Headers?.[k] + if (!vl || vl.length === 0) { + return + } + vl.forEach(v => { + const e = dom.tr( + dom.td(k+':', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td(v), + ) + msgheaderElem.appendChild(e) + }) + }) + } + + const htmlNote = 'In the HTML viewer, the following potentially dangerous functionality is disabled: submitting forms, starting a download from a link, navigating away from this page by clicking a link. If a link does not work, try explicitly opening it in a new tab.' + const haveText = pm.Texts && pm.Texts.length > 0 + if (!haveText && !pm.HasHTML) { + dom._kids(msgcontentElem) + dom._kids(msgmodeElem, + dom.div(dom._class('pad'), + style({borderTop: '1px solid #ccc'}), + dom.span('No textual content', style({backgroundColor: '#ffca91', padding: '0 .15em'})), + ), + ) + } else if (haveText && !pm.HasHTML) { + loadText(pm) + dom._kids(msgmodeElem) + } else if (!haveText && pm.HasHTML) { + loadHTML() + dom._kids(msgmodeElem, + dom.div(dom._class('pad'), + style({borderTop: '1px solid #ccc'}), + dom.span('HTML-only message', attr.title(htmlNote), style({backgroundColor: '#ffca91', padding: '0 .15em'})), + ), + ) + } else { + dom._kids(msgmodeElem, + dom.div(dom._class('pad'), + style({borderTop: '1px solid #ccc'}), + dom.span(dom._class('btngroup'), + textbtn=dom.clickbutton(settings.showHTML ? [] : dom._class('active'), 'Text', clickCmd(cmdShowText, shortcuts)), + htmlbtn=dom.clickbutton(!settings.showHTML ? [] : dom._class('active'), 'HTML', attr.title(htmlNote), async function click() { + // Shortcuts has a function that cycles through html and htmlexternal. + showShortcut('X') + await cmdShowHTML() + }), + htmlextbtn=dom.clickbutton('HTML with external resources', attr.title(htmlNote), clickCmd(cmdShowHTMLExternal, shortcuts)), + ), + ) + ) + if (settings.showHTML) { + loadHTML() + } else { + loadText(pm) + } + } + + messageLoaded() + + if (!miv.messageitem.Message.Seen) { + window.setTimeout(async () => { + if (!miv.messageitem.Message.Seen && miv.messageitem.Message.ID === msglistView.activeMessageID()) { + await withStatus('Marking current message as read', client.FlagsAdd([miv.messageitem.Message.ID], ['\\seen'])) + } + }, 500) + } + })() + + return mv +} + +// MsglistView holds the list of messages for a mailbox/search query. Zero or more +// messages can be selected (active). If one message is selected, its contents are shown. +// With multiple selected, they can all be operated on, e.g. moved to +// archive/trash/junk. Focus is typically on the last clicked message, but can be +// changed with keyboard interaction without changing selected messages. +// +// We just have one MsglistView, that is updated when a +// different mailbox/search query is opened. +interface MsglistView { + root: HTMLElement + updateFlags: (mailboxID: number, uid: number, mask: api.Flags, flags: api.Flags, keywords: string[]) => void + addMessageItems: (messageItems: api.MessageItem[]) => void + removeUIDs: (mailboxID: number, uids: number[]) => void + activeMessageID: () => number // For single message selected, otherwise returns 0. + redraw: (miv: MsgitemView) => void // To be called after updating flags or focus/active state, rendering it again. + anchorMessageID: () => number // For next request, for more messages. + addMsgitemViews: (mivs: MsgitemView[]) => void + clear: () => void // Clear all messages, reset focus/active state. + unselect: () => void + select: (miv: MsgitemView) => void + selected: () => MsgitemView[] + openMessage: (miv: MsgitemView, initial: boolean, parsedMessageOpt?: api.ParsedMessage) => void + click: (miv: MsgitemView, ctrl: boolean, shift: boolean) => void + key: (k: string, e: KeyboardEvent) => void + mailboxes: () => api.Mailbox[] + itemHeight: () => number // For calculating how many messageitems to request to load next view. + + // Exported for MsgView. + cmdArchive: () => Promise + cmdDelete: () => Promise + cmdTrash: () => Promise + cmdJunk: () => Promise + cmdMarkNotJunk: () => Promise + cmdMarkRead: () => Promise + cmdMarkUnread: () => Promise +} + +const newMsglistView = (msgElem: HTMLElement, listMailboxes: listMailboxes, setLocationHash: setLocationHash, otherMailbox: otherMailbox, possibleLabels: possibleLabels, scrollElemHeight: () => number, refineKeyword: (kw: string) => Promise): MsglistView => { + // These contain one msgitemView or an array of them. + // Zero or more selected msgitemViews. If there is a single message, its content is + // shown. If there are multiple, just the count is shown. These are in order of + // being added, not in order of how they are shown in the list. This is needed to + // handle selection changes with the shift key. + let selected: MsgitemView[] = [] + + // MsgitemView last interacted with, or the first when messages are loaded. Always + // set when there is a message. Used for shift+click to expand selection. + let focus: MsgitemView | null = null + + let msgitemViews: MsgitemView[] = [] + let msgView: MsgView | null = null + + const cmdArchive = async () => { + const mb = listMailboxes().find(mb => mb.Archive) + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID) + await withStatus('Moving to archive mailbox', client.MessageMove(msgIDs, mb.ID)) + } else { + window.alert('No mailbox configured for archiving yet.') + } + } + const cmdDelete = async () => { + if (!confirm('Are you sure you want to permanently delete?')) { + return + } + await withStatus('Permanently deleting messages', client.MessageDelete(selected.map(miv => miv.messageitem.Message.ID))) + } + const cmdTrash = async () => { + const mb = listMailboxes().find(mb => mb.Trash) + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID) + await withStatus('Moving to trash mailbox', client.MessageMove(msgIDs, mb.ID)) + } else { + window.alert('No mailbox configured for trash yet.') + } + } + const cmdJunk = async () => { + const mb = listMailboxes().find(mb => mb.Junk) + if (mb) { + const msgIDs = selected.filter(miv => miv.messageitem.Message.MailboxID !== mb.ID).map(miv => miv.messageitem.Message.ID) + await withStatus('Moving to junk mailbox', client.MessageMove(msgIDs, mb.ID)) + } else { + window.alert('No mailbox configured for junk yet.') + } + } + const cmdMarkNotJunk = async () => { await withStatus('Marking as not junk', client.FlagsAdd(selected.map(miv => miv.messageitem.Message.ID), ['$notjunk'])) } + const cmdMarkRead = async () => { await withStatus('Marking as read', client.FlagsAdd(selected.map(miv => miv.messageitem.Message.ID), ['\\seen'])) } + const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(selected.map(miv => miv.messageitem.Message.ID), ['\\seen'])) } + + const shortcuts: {[key: string]: command} = { + d: cmdTrash, + Delete: cmdTrash, + D: cmdDelete, + q: cmdJunk, + a: cmdArchive, + n: cmdMarkNotJunk, + u: cmdMarkUnread, + m: cmdMarkRead, + } + + type state = { + active: {[id: string]: MsgitemView}, + focus: MsgitemView | null + } + + // Return active & focus state, and update the UI after changing state. + const state = (): state => { + const active: {[key: string]: MsgitemView} = {} + for (const miv of selected) { + active[miv.messageitem.Message.ID] = miv + } + return {active: active, focus: focus} + } + const updateState = async (oldstate: state, initial?: boolean, parsedMessageOpt?: api.ParsedMessage): Promise => { + // Set new focus & active classes. + const newstate = state() + if (oldstate.focus !== newstate.focus) { + if (oldstate.focus) { + oldstate.focus.root.classList.toggle('focus', false) + } + if (newstate.focus) { + newstate.focus.root.classList.toggle('focus', true) + newstate.focus.root.scrollIntoView({block: initial ? 'center' : 'nearest'}) + } + } + let activeChanged = false + for (const id in oldstate.active) { + if (!newstate.active[id]) { + oldstate.active[id].root.classList.toggle('active', false) + activeChanged = true + } + } + for (const id in newstate.active) { + if (!oldstate.active[id]) { + newstate.active[id].root.classList.toggle('active', true) + activeChanged = true + } + } + + if (initial && selected.length === 1) { + mlv.redraw(selected[0]) + } + + if (activeChanged) { + if (msgView) { + msgView.aborter.abort() + } + msgView = null + + if (selected.length === 0) { + dom._kids(msgElem) + } else if (selected.length === 1) { + msgElem.classList.toggle('loading', true) + const loaded = () => { msgElem.classList.toggle('loading', false) } + msgView = newMsgView(selected[0], mlv, listMailboxes, possibleLabels, loaded, refineKeyword, parsedMessageOpt) + dom._kids(msgElem, msgView) + } else { + const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID + const allTrash = trashMailboxID && !selected.find(miv => miv.messageitem.Message.MailboxID !== trashMailboxID) + dom._kids(msgElem, + dom.div( + attr.role('region'), attr.arialabel('Buttons for multiple messages'), + style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}), + dom.div( + style({padding: '4ex', backgroundColor: 'white', borderRadius: '.25em', border: '1px solid #ccc'}), + dom.div( + style({textAlign: 'center', marginBottom: '4ex'}), + ''+selected.length+' messages selected', + ), + dom.div( + dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(cmdArchive, shortcuts)), ' ', + allTrash ? + dom.clickbutton('Delete', attr.title('Permanently delete messages.'), clickCmd(cmdDelete, shortcuts)) : + dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(cmdTrash, shortcuts)), + ' ', + dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdJunk, shortcuts)), ' ', + dom.clickbutton('Move to...', function click(e: MouseEvent) { + movePopover(e, listMailboxes(), selected.map(miv => miv.messageitem.Message)) + }), ' ', + dom.clickbutton('Labels...', attr.title('Add/remove labels ...'), function click(e: MouseEvent) { + labelsPopover(e, selected.map(miv => miv.messageitem.Message), possibleLabels) + }), ' ', + dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', + dom.clickbutton('Mark read', clickCmd(cmdMarkRead, shortcuts)), ' ', + dom.clickbutton('Mark unread', clickCmd(cmdMarkUnread, shortcuts)), + ), + ), + ), + ) + } + } + if (activeChanged) { + setLocationHash() + } + } + + // Moves the currently focused msgitemView, without changing selection. + const moveFocus = (miv: MsgitemView) => { + const oldstate = state() + focus = miv + updateState(oldstate) + } + + const mlv: MsglistView = { + root: dom.div(), + + updateFlags: (mailboxID: number, uid: number, mask: api.Flags, flags: api.Flags, keywords: string[]) => { + // todo optimize: keep mapping of uid to msgitemView for performance. instead of using Array.find + const miv = msgitemViews.find(miv => miv.messageitem.Message.MailboxID === mailboxID && miv.messageitem.Message.UID === uid) + if (!miv) { + // Happens for messages outside of view. + log('could not find msgitemView for uid', uid) + return + } + miv.updateFlags(mask, flags, keywords) + if (msgView && msgView.messageitem.Message.ID === miv.messageitem.Message.ID) { + msgView.updateKeywords(keywords) + } + }, + + addMessageItems: (messageItems: api.MessageItem[]) => { + if (messageItems.length === 0) { + return + } + messageItems.forEach(mi => { + const miv = newMsgitemView(mi, mlv, otherMailbox(mi.Message.MailboxID)) + const orderNewest = !settings.orderAsc + const tm = mi.Message.Received.getTime() + const nextmivindex = msgitemViews.findIndex(miv => { + const vtm = miv.messageitem.Message.Received.getTime() + return orderNewest && vtm <= tm || !orderNewest && tm <= vtm + }) + if (nextmivindex < 0) { + mlv.root.appendChild(miv.root) + msgitemViews.push(miv) + } else { + mlv.root.insertBefore(miv.root, msgitemViews[nextmivindex].root) + msgitemViews.splice(nextmivindex, 0, miv) + } + }) + const oldstate = state() + if (!focus) { + focus = msgitemViews[0] + } + if (selected.length === 0) { + selected = [msgitemViews[0]] + } + updateState(oldstate) + }, + removeUIDs: (mailboxID: number, uids: number[]) => { + const uidmap: {[key: string]: boolean} = {} + uids.forEach(uid => uidmap[''+mailboxID+','+uid] = true) // todo: we would like messageID here. + + const key = (miv: MsgitemView) => ''+miv.messageitem.Message.MailboxID+','+miv.messageitem.Message.UID + + const oldstate = state() + selected = selected.filter(miv => !uidmap[key(miv)]) + if (focus && uidmap[key(focus)]) { + const index = msgitemViews.indexOf(focus) + var nextmiv + for (let i = index+1; i < msgitemViews.length; i++) { + if (!uidmap[key(msgitemViews[i])]) { + nextmiv = msgitemViews[i] + break + } + } + if (!nextmiv) { + for (let i = index-1; i >= 0; i--) { + if (!uidmap[key(msgitemViews[i])]) { + nextmiv = msgitemViews[i] + break + } + } + } + + if (nextmiv) { + focus = nextmiv + } else { + focus = null + } + } + + if (selected.length === 0 && focus) { + selected = [focus] + } + updateState(oldstate) + + let i = 0 + while (i < msgitemViews.length) { + const miv = msgitemViews[i] + const k = ''+miv.messageitem.Message.MailboxID+','+miv.messageitem.Message.UID + if (!uidmap[k]) { + i++ + continue + } + miv.remove() + msgitemViews.splice(i, 1) + } + }, + + // For location hash. + activeMessageID: () => selected.length === 1 ? selected[0].messageitem.Message.ID : 0, + + redraw: (miv: MsgitemView) => { + miv.root.classList.toggle('focus', miv === focus) + miv.root.classList.toggle('active', selected.indexOf(miv) >= 0) + }, + + anchorMessageID: () => msgitemViews[msgitemViews.length-1].messageitem.Message.ID, + + addMsgitemViews: (mivs: MsgitemView[]) => { + mlv.root.append(...mivs.map(v => v.root)) + msgitemViews.push(...mivs) + }, + + clear: (): void => { + dom._kids(mlv.root) + msgitemViews.forEach(miv => miv.remove()) + msgitemViews = [] + focus = null + selected = [] + dom._kids(msgElem) + setLocationHash() + }, + + unselect: (): void => { + const oldstate = state() + selected = [] + updateState(oldstate) + }, + + select: (miv: MsgitemView): void => { + const oldstate = state() + focus = miv + selected = [miv] + updateState(oldstate) + }, + selected: () => selected, + openMessage: (miv: MsgitemView, initial: boolean, parsedMessageOpt?: api.ParsedMessage) => { + const oldstate = state() + focus = miv + selected = [miv] + updateState(oldstate, initial, parsedMessageOpt) + }, + + click: (miv: MsgitemView, ctrl: boolean, shift: boolean) => { + if (msgitemViews.length === 0) { + return + } + + const oldstate = state() + if (shift) { + const mivindex = msgitemViews.indexOf(miv) + // Set selection from start of most recent range. + let recentindex + if (selected.length > 0) { + let o = selected.length-1 + recentindex = msgitemViews.indexOf(selected[o]) + while (o > 0) { + if (selected[o-1] === msgitemViews[recentindex-1]) { + recentindex-- + } else if(selected[o-1] === msgitemViews[recentindex+1]) { + recentindex++ + } else { + break + } + o-- + } + } else { + recentindex = mivindex + } + const oselected = selected + if (mivindex < recentindex) { + selected = msgitemViews.slice(mivindex, recentindex+1) + selected.reverse() + } else { + selected = msgitemViews.slice(recentindex, mivindex+1) + } + if (ctrl) { + selected = oselected.filter(e => !selected.includes(e)).concat(selected) + } + } else if (ctrl) { + const index = selected.indexOf(miv) + if (index < 0) { + selected.push(miv) + } else { + selected.splice(index, 1) + } + } else { + selected = [miv] + } + focus = miv + updateState(oldstate) + }, + + key: async (k: string, e: KeyboardEvent) => { + if (attachmentView) { + attachmentView.key(k, e) + return + } + + const moveKeys = [ + ' ', 'ArrowUp', 'ArrowDown', + 'PageUp', 'h', 'H', + 'PageDown', 'l', 'L', + 'j', 'J', + 'k', 'K', + 'Home', ',', '<', + 'End', '.', '>', + ] + if (!e.altKey && moveKeys.includes(e.key)) { + const moveclick = (index: number, clip: boolean) => { + if (clip && index < 0) { + index = 0 + } else if (clip && index >= msgitemViews.length) { + index = msgitemViews.length-1 + } + if (index < 0 || index >= msgitemViews.length) { + return + } + if (e.ctrlKey) { + moveFocus(msgitemViews[index]) + } else { + mlv.click(msgitemViews[index], false, e.shiftKey) + } + } + + let i = msgitemViews.findIndex(miv => miv === focus) + if (e.key === ' ') { + if (i >= 0) { + mlv.click(msgitemViews[i], e.ctrlKey, e.shiftKey) + } + } else if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') { + moveclick(i-1, e.key === 'K') + } else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') { + moveclick(i+1, e.key === 'J') + } else if (e.key === 'PageUp' || e.key === 'h' || e.key == 'H' || e.key === 'PageDown' || e.key === 'l' || e.key === 'L') { + if (msgitemViews.length > 0) { + let n = Math.max(1, Math.floor(scrollElemHeight()/mlv.itemHeight())-1) + if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H') { + n = -n + } + moveclick(i + n, true) + } + } else if (e.key === 'Home' || e.key === ',' || e.key === '<') { + moveclick(0, true) + } else if (e.key === 'End' || e.key === '.' || e.key === '>') { + moveclick(msgitemViews.length-1, true) + } + e.preventDefault() + e.stopPropagation() + return + } + const fn = shortcuts[k] + if (fn) { + e.preventDefault() + e.stopPropagation() + fn() + } else if (msgView) { + msgView.key(k, e) + } else { + log('key not handled', k) + } + }, + mailboxes: () => listMailboxes(), + itemHeight: () => msgitemViews.length > 0 ? msgitemViews[0].root.getBoundingClientRect().height : 25, + + cmdArchive: cmdArchive, + cmdTrash: cmdTrash, + cmdDelete: cmdDelete, + cmdJunk: cmdJunk, + cmdMarkNotJunk: cmdMarkNotJunk, + cmdMarkRead: cmdMarkRead, + cmdMarkUnread: cmdMarkUnread, + } + + return mlv +} + +// MailboxView is a single mailbox item in the list of mailboxes. It is a drag and +// drop target for messages. It can be hidden, when a parent/ancestor is collapsed. +// It can be collapsed itself, causing it to still be visible, but its children +// hidden. +interface MailboxView { + root: HTMLElement + + // Changed by the MailboxlistView. + shortname: string // Just the last part of the slash-separated name. + parents: number // How many parents/ancestors, for indenting. + hidden: boolean // If currently hidden. + + mailbox: api.Mailbox + update: () => void // Render again, e.g. after toggling hiddenness. + open: (load: boolean) => Promise // Open mailbox, clearing MsglistView and, if load is set, requesting messages. + setCounts: (total:number, unread: number) => void + setSpecialUse: (specialUse: api.SpecialUse) => void + setKeywords: (keywords: string[]) => void +} + +const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView): MailboxView => { + const plusbox = '⊞' + const minusbox = '⊟' + const cmdCollapse = async () => { + settings.mailboxCollapsed[mbv.mailbox.ID] = true + settingsPut(settings) + mailboxlistView.updateHidden() + mbv.root.focus() + } + const cmdExpand = async () => { + delete(settings.mailboxCollapsed[mbv.mailbox.ID]) + settingsPut(settings) + mailboxlistView.updateHidden() + mbv.root.focus() + } + const collapseElem = dom.span(dom._class('mailboxcollapse'), minusbox, function click(e: MouseEvent) { + e.stopPropagation() + cmdCollapse() + }) + const expandElem = dom.span(plusbox, function click(e: MouseEvent) { + e.stopPropagation() + cmdExpand() + }) + + let name: HTMLElement, unread: HTMLElement + let actionBtn: HTMLButtonElement + + const cmdOpenActions = async () => { + const trashmb = mailboxlistView.mailboxes().find(mb => mb.Trash) + + const remove = popover(actionBtn, {transparent: true}, + dom.div(style({display: 'flex', flexDirection: 'column', gap: '.5ex'}), + dom.div( + dom.clickbutton('Move to trash', attr.title('Move mailbox, its messages and its mailboxes to the trash.'), async function click() { + if (!trashmb) { + window.alert('No mailbox configured for trash yet.') + return + } + if (!window.confirm('Are you sure you want to move this mailbox, its messages and its mailboxes to the trash?')) { + return + } + remove() + await withStatus('Moving mailbox to trash', client.MailboxRename(mbv.mailbox.ID, trashmb.Name + '/' + mbv.mailbox.Name)) + }), + ), + dom.div( + dom.clickbutton('Delete mailbox', attr.title('Permanently delete this mailbox and all its messages.'), async function click() { + if (!window.confirm('Are you sure you want to permanently delete this mailbox and all its messages?')) { + return + } + remove() + await withStatus('Deleting mailbox', client.MailboxDelete(mbv.mailbox.ID)) + }), + ), + dom.div( + dom.clickbutton('Empty mailbox', async function click() { + if (!window.confirm('Are you sure you want to empty this mailbox, permanently removing its messages? Mailboxes inside this mailbox are not affected.')) { + return + } + remove() + await withStatus('Emptying mailbox', client.MailboxEmpty(mbv.mailbox.ID)) + }), + ), + dom.div( + dom.clickbutton('Rename mailbox', function click() { + remove() + + let fieldset: HTMLFieldSetElement, name: HTMLInputElement + + const remove2 = popover(actionBtn, {}, + dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + await withStatus('Renaming mailbox', client.MailboxRename(mbv.mailbox.ID, name.value), fieldset) + remove2() + }, + fieldset=dom.fieldset( + dom.label( + 'Name ', + name=dom.input(attr.required(''), attr.value(mbv.mailbox.Name), prop({selectionStart: 0, selectionEnd: mbv.mailbox.Name.length})), + ), + ' ', + dom.submitbutton('Rename'), + ), + ), + ) + }), + ), + dom.div( + dom.clickbutton('Set role for mailbox...', attr.title('Set a special-use role on the mailbox, making it the designated mailbox for either Archived, Sent, Draft, Trashed or Junk messages.'), async function click() { + remove() + + const setUse = async (set: (mb: api.Mailbox) => void) => { + const mb = {...mbv.mailbox} + mb.Archive = mb.Draft = mb.Junk = mb.Sent = mb.Trash = false + set(mb) + await withStatus('Marking mailbox as special use', client.MailboxSetSpecialUse(mb)) + } + popover(actionBtn, {transparent: true}, + dom.div(style({display: 'flex', flexDirection: 'column', gap: '.5ex'}), + dom.div(dom.clickbutton('Archive', async function click() { await setUse((mb: api.Mailbox) => { mb.Archive = true }) })), + dom.div(dom.clickbutton('Draft', async function click() { await setUse((mb: api.Mailbox) => { mb.Draft = true }) })), + dom.div(dom.clickbutton('Junk', async function click() { await setUse((mb: api.Mailbox) => { mb.Junk = true }) })), + dom.div(dom.clickbutton('Sent', async function click() { await setUse((mb: api.Mailbox) => { mb.Sent = true }) })), + dom.div(dom.clickbutton('Trash', async function click() { await setUse((mb: api.Mailbox) => { mb.Trash = true }) })), + ), + ) + }), + ), + ), + ) + } + + // Keep track of dragenter/dragleave ourselves, we don't get a neat 1 enter and 1 + // leave event from browsers, we get events for multiple of this elements children. + let drags = 0 + + const root = dom.div(dom._class('mailboxitem'), + attr.tabindex('0'), + async function keydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.stopPropagation() + await withStatus('Opening mailbox', mbv.open(true)) + } else if (e.key === 'ArrowLeft') { + e.stopPropagation() + if (!mailboxlistView.mailboxLeaf(mbv)) { + cmdCollapse() + } + } else if (e.key === 'ArrowRight') { + e.stopPropagation() + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + cmdExpand() + } + } else if (e.key === 'b') { + cmdOpenActions() + } + }, + async function click() { + mbv.root.focus() + await withStatus('Opening mailbox', mbv.open(true)) + }, + function dragover(e: DragEvent) { + e.preventDefault() + e.dataTransfer!.dropEffect = 'move' + }, + function dragenter(e: DragEvent) { + e.stopPropagation() + drags++ + mbv.root.classList.toggle('dropping', true) + }, + function dragleave(e: DragEvent) { + e.stopPropagation() + drags-- + if (drags <= 0) { + mbv.root.classList.toggle('dropping', false) + } + }, + async function drop(e: DragEvent) { + e.preventDefault() + mbv.root.classList.toggle('dropping', false) + const msgIDs = JSON.parse(e.dataTransfer!.getData('application/vnd.mox.messages')) as number[] + await withStatus('Moving to '+xmb.Name, client.MessageMove(msgIDs, xmb.ID)) + }, + dom.div(dom._class('mailbox'), + style({display: 'flex', justifyContent: 'space-between'}), + name=dom.div(style({whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'})), + dom.div( + style({whiteSpace: 'nowrap'}), + actionBtn=dom.clickbutton(dom._class('mailboxhoveronly'), + '...', + attr.tabindex('-1'), // Without, tab breaks because this disappears when mailbox loses focus. + attr.arialabel('Mailbox actions'), + attr.title('Actions on mailbox, like deleting, emptying, renaming.'), + function click(e: MouseEvent) { + e.stopPropagation() + cmdOpenActions() + }, + ), + ' ', + unread=dom.b(dom._class('silenttitle')), + ), + ), + ) + + const update = () => { + let moreElems: any[] = [] + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + moreElems = [' ', expandElem] + } else if (!mailboxlistView.mailboxLeaf(mbv)) { + moreElems = [' ', collapseElem] + } + let ntotal = mbv.mailbox.Total + let nunread = mbv.mailbox.Unread + if (settings.mailboxCollapsed[mbv.mailbox.ID]) { + const prefix = mbv.mailbox.Name+'/' + for (const mb of mailboxlistView.mailboxes()) { + if (mb.Name.startsWith(prefix)) { + ntotal += mb.Total + nunread += mb.Unread + } + } + } + dom._kids(name, dom.span(mbv.parents > 0 ? style({paddingLeft: ''+(mbv.parents*2/3)+'em'}) : [], mbv.shortname, attr.title('Total messages: ' + ntotal), moreElems)) + dom._kids(unread, nunread === 0 ? ['', attr.title('')] : [''+nunread, attr.title(''+nunread+' unread')]) + } + + const mbv = { + root: root, + + // Set by update(), typically through MailboxlistView updateMailboxNames after inserting. + shortname: '', + parents: 0, + hidden: false, + + update: update, + mailbox: xmb, + open: async (load: boolean) => { + await mailboxlistView.openMailboxView(mbv, load, false) + }, + setCounts: (total: number, unread: number) => { + mbv.mailbox.Total = total + mbv.mailbox.Unread = unread + // If mailbox is collapsed, parent needs updating. + // todo optimize: only update parents, not all. + mailboxlistView.updateCounts() + }, + setSpecialUse: (specialUse: api.SpecialUse) => { + mbv.mailbox.Archive = specialUse.Archive + mbv.mailbox.Draft = specialUse.Draft + mbv.mailbox.Junk = specialUse.Junk + mbv.mailbox.Sent = specialUse.Sent + mbv.mailbox.Trash = specialUse.Trash + }, + setKeywords: (keywords: string[]) => { + mbv.mailbox.Keywords = keywords + }, + } + return mbv +} + +// MailboxlistView is the list on the left with all mailboxes. It holds MailboxViews. +interface MailboxlistView { + root: HTMLElement + + loadMailboxes: (mailboxes: api.Mailbox[], mbnameOpt?: string) => void + closeMailbox: () => void + openMailboxView: (mbv: MailboxView, load: boolean, focus: boolean) => Promise + mailboxLeaf: (mbv: MailboxView) => boolean + updateHidden: () => void + + updateCounts: () => void + activeMailbox: () => api.Mailbox | null + mailboxes: () => api.Mailbox[] + findMailboxByID: (id: number) => api.Mailbox | null + findMailboxByName: (name: string) => api.Mailbox | null + + openMailboxID: (id: number, focus: boolean) => Promise + + // For change events. + addMailbox: (mb: api.Mailbox) => void + renameMailbox: (mailboxID: number, newName: string) => void + removeMailbox: (mailboxID: number) => void + setMailboxCounts: (mailboxID: number, total: number, unread: number) => void + setMailboxSpecialUse: (mailboxID: number, specialUse: api.SpecialUse) => void + setMailboxKeywords: (mailboxID: number, keywords: string[]) => void +} + +const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNewView, updatePageTitle: updatePageTitle, setLocationHash: setLocationHash, unloadSearch: unloadSearch): MailboxlistView => { + let mailboxViews: MailboxView[] = [] + let mailboxViewActive: MailboxView | null + + // Reorder mailboxes and assign new short names and indenting. Called after changing the list. + const updateMailboxNames = () => { + const draftmb = mailboxViews.find(mbv => mbv.mailbox.Draft)?.mailbox + const sentmb = mailboxViews.find(mbv => mbv.mailbox.Sent)?.mailbox + const archivemb = mailboxViews.find(mbv => mbv.mailbox.Archive)?.mailbox + const trashmb = mailboxViews.find(mbv => mbv.mailbox.Trash)?.mailbox + const junkmb = mailboxViews.find(mbv => mbv.mailbox.Junk)?.mailbox + const stem = (s: string) => s.split('/')[0] + const specialUse = [ + (mb: api.Mailbox) => stem(mb.Name) === 'Inbox', + (mb: api.Mailbox) => draftmb && stem(mb.Name) === stem(draftmb.Name), + (mb: api.Mailbox) => sentmb && stem(mb.Name) === stem(sentmb.Name), + (mb: api.Mailbox) => archivemb && stem(mb.Name) === stem(archivemb.Name), + (mb: api.Mailbox) => trashmb && stem(mb.Name) === stem(trashmb.Name), + (mb: api.Mailbox) => junkmb && stem(mb.Name) === stem(junkmb.Name), + ] + mailboxViews.sort((mbva, mbvb) => { + const ai = specialUse.findIndex(fn => fn(mbva.mailbox)) + const bi = specialUse.findIndex(fn => fn(mbvb.mailbox)) + if (ai < 0 && bi >= 0) { + return 1 + } else if (ai >= 0 && bi < 0) { + return -1 + } else if (ai >= 0 && bi >= 0 && ai !== bi) { + return ai < bi ? -1 : 1 + } + return mbva.mailbox.Name < mbvb.mailbox.Name ? -1 : 1 + }) + + let prevmailboxname: string = '' + mailboxViews.forEach(mbv => { + const mb = mbv.mailbox + let shortname = mb.Name + let parents = 0 + if (prevmailboxname) { + let prefix = '' + for (const s of prevmailboxname.split('/')) { + const nprefix = prefix + s + '/' + if (mb.Name.startsWith(nprefix)) { + prefix = nprefix + parents++ + } else { + break + } + } + shortname = mb.Name.substring(prefix.length) + } + mbv.shortname = shortname + mbv.parents = parents + mbv.update() // Render name. + prevmailboxname = mb.Name + }) + + updateHidden() + } + + const mailboxHidden = (mb: api.Mailbox, mailboxesMap: {[key: string]: api.Mailbox}) => { + let s = '' + for (const e of mb.Name.split('/')) { + if (s) { + s += '/' + } + s += e + const pmb = mailboxesMap[s] + if (pmb && settings.mailboxCollapsed[pmb.ID] && s !== mb.Name) { + return true + } + } + return false + } + + const mailboxLeaf = (mbv: MailboxView) => { + const index = mailboxViews.findIndex(v => v === mbv) + const prefix = mbv.mailbox.Name+'/' + const r = index < 0 || index+1 >= mailboxViews.length || !mailboxViews[index+1].mailbox.Name.startsWith(prefix) + return r + } + + const updateHidden = () => { + const mailboxNameMap: {[key: string]: api.Mailbox} = {} + mailboxViews.forEach((mbv) => mailboxNameMap[mbv.mailbox.Name] = mbv.mailbox) + for(const mbv of mailboxViews) { + mbv.hidden = mailboxHidden(mbv.mailbox, mailboxNameMap) + } + mailboxViews.forEach(mbv => mbv.update()) + dom._kids(mailboxesElem, mailboxViews.filter(mbv => !mbv.hidden)) + } + + const root = dom.div() + const mailboxesElem = dom.div() + + dom._kids(root, + dom.div(attr.role('region'), attr.arialabel('Mailboxes'), + dom.div( + dom.h1('Mailboxes', style({display: 'inline', fontSize: 'inherit'})), + ' ', + dom.clickbutton('+', attr.arialabel('Create new mailbox.'), attr.title('Create new mailbox.'), style({padding: '0 .25em'}), function click(e: MouseEvent) { + let fieldset: HTMLFieldSetElement, name: HTMLInputElement + + const remove = popover(e.target! as HTMLElement, {}, + dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + await withStatus('Creating mailbox', client.MailboxCreate(name.value), fieldset) + remove() + }, + fieldset=dom.fieldset( + dom.label( + 'Name ', + name=dom.input(attr.required('yes'), focusPlaceholder('Lists/Go/Nuts')), + ), + ' ', + dom.submitbutton('Create'), + ), + ), + ) + }), + ), + mailboxesElem, + ), + ) + + const loadMailboxes = (mailboxes: api.Mailbox[], mbnameOpt?: string) => { + mailboxViews = mailboxes.map(mb => newMailboxView(mb, mblv)) + updateMailboxNames() + if (mbnameOpt) { + const mbv = mailboxViews.find(mbv => mbv.mailbox.Name === mbnameOpt) + if (mbv) { + openMailboxView(mbv, false, false) + } + } + } + + const closeMailbox = () => { + if (!mailboxViewActive) { + return + } + mailboxViewActive.root.classList.toggle('active', false) + mailboxViewActive = null + updatePageTitle() + } + + const openMailboxView = async (mbv: MailboxView, load: boolean, focus: boolean): Promise => { + + // Ensure searchbarElem is in inactive state. + unloadSearch() + + if (mailboxViewActive) { + mailboxViewActive.root.classList.toggle('active', false) + } + + mailboxViewActive = mbv + mbv.root.classList.toggle('active', true) + + updatePageTitle() + + if (load) { + setLocationHash() + const f = newFilter() + f.MailboxID = mbv.mailbox.ID + await withStatus('Requesting messages', requestNewView(true, f, newNotFilter())) + } else { + msglistView.clear() + setLocationHash() + } + if (focus) { + mbv.root.focus() + } + } + + const mblv = { + root: root, + loadMailboxes: loadMailboxes, + closeMailbox: closeMailbox, + openMailboxView: openMailboxView, + mailboxLeaf: mailboxLeaf, + updateHidden: updateHidden, + + updateCounts: (): void => mailboxViews.forEach(mbv => mbv.update()), + + activeMailbox: () => mailboxViewActive ? mailboxViewActive.mailbox : null, + mailboxes: (): api.Mailbox[] => mailboxViews.map(mbv => mbv.mailbox), + findMailboxByID: (id: number): api.Mailbox | null => mailboxViews.find(mbv => mbv.mailbox.ID === id)?.mailbox || null, + findMailboxByName: (name: string): api.Mailbox | null => mailboxViews.find(mbv => mbv.mailbox.Name === name)?.mailbox || null, + + openMailboxID: async (id: number, focus: boolean): Promise => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === id) + if (mbv) { + await openMailboxView(mbv, false, focus) + } else { + throw new Error('unknown mailbox') + } + }, + + addMailbox: (mb: api.Mailbox): void => { + const mbv = newMailboxView(mb, mblv) + mailboxViews.push(mbv) + updateMailboxNames() + }, + + renameMailbox: (mailboxID: number, newName: string): void => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID) + if (!mbv) { + throw new Error('rename event: unknown mailbox') + } + mbv.mailbox.Name = newName + updateMailboxNames() + }, + + removeMailbox: (mailboxID: number): void => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID) + if (!mbv) { + throw new Error('remove event: unknown mailbox') + } + if (mbv === mailboxViewActive) { + const inboxv = mailboxViews.find(mbv => mbv.mailbox.Name === 'Inbox') + if (inboxv) { + openMailboxView(inboxv, true, false) // note: async function + } + } + const index = mailboxViews.findIndex(mbv => mbv.mailbox.ID === mailboxID) + mailboxViews.splice(index, 1) + updateMailboxNames() + }, + + setMailboxCounts: (mailboxID: number, total: number, unread: number): void => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID) + if (!mbv) { + throw new Error('mailbox message/unread count changed: unknown mailbox') + } + mbv.setCounts(total, unread) + if (mbv === mailboxViewActive) { + updatePageTitle() + } + }, + + setMailboxSpecialUse: (mailboxID: number, specialUse: api.SpecialUse): void => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID) + if (!mbv) { + throw new Error('special-use flags changed: unknown mailbox') + } + mbv.setSpecialUse(specialUse) + updateMailboxNames() + }, + + setMailboxKeywords: (mailboxID: number, keywords: string[]): void => { + const mbv = mailboxViews.find(mbv => mbv.mailbox.ID === mailboxID) + if (!mbv) { + throw new Error('keywords changed: unknown mailbox') + } + mbv.setKeywords(keywords) + }, + } + return mblv +} + +interface SearchView { + root: HTMLElement + submit: () => Promise + ensureLoaded: () => void // For loading mailboxes into the select dropdown, after SSE connection sent list of mailboxes. + updateForm: () => void +} + +const newSearchView = (searchbarElem: HTMLInputElement, mailboxlistView: MailboxlistView, startSearch: (f: api.Filter, notf: api.NotFilter) => Promise, searchViewClose: () => void) => { + interface FlagView { + active: boolean | null + flag: string + root: HTMLElement + update: () => void + } + + let form: HTMLFormElement + let words: HTMLInputElement, mailbox: HTMLSelectElement, mailboxkids: HTMLInputElement, from: HTMLInputElement, to: HTMLInputElement, oldestDate: HTMLInputElement, oldestTime: HTMLInputElement, newestDate: HTMLInputElement, newestTime: HTMLInputElement, subject: HTMLInputElement, flagViews: FlagView[], labels: HTMLInputElement, minsize: HTMLInputElement, maxsize: HTMLInputElement + let attachmentNone: HTMLInputElement, attachmentAny: HTMLInputElement, attachmentImage: HTMLInputElement, attachmentPDF: HTMLInputElement, attachmentArchive: HTMLInputElement, attachmentSpreadsheet: HTMLInputElement, attachmentDocument: HTMLInputElement, attachmentPresentation: HTMLInputElement + + const makeDateTime = (dt: string, tm: string): string => { + if (!dt && !tm) { + return '' + } + if (!dt) { + const now = new Date() + const pad0 = (v: number) => v <= 9 ? '0'+v : ''+v + dt = [now.getFullYear(), pad0(now.getMonth()+1), pad0(now.getDate())].join('-') + } + if (dt && tm) { + return dt+'T'+tm + } + return dt + } + + const packString = (s: string): string => needsDquote(s) ? dquote(s) : s + const packNotString = (s: string): string => '-' + (needsDquote(s) || s.startsWith('-') ? dquote(s) : s) + + // Sync the form fields back into the searchbarElem. We process in order of the form, + // so we may rearrange terms. We also canonicalize quoting and space and remove + // empty strings. + const updateSearchbar = (): void => { + let tokens: Token[] = [] + if (mailbox.value && mailbox.value !== '-1') { + const v = mailbox.value === '0' ? '' : mailbox.selectedOptions[0].text // '0' is "All mailboxes", represented as "mb:". + tokens.push([false, 'mb', false, v]) + } + if (mailboxkids.checked) { + tokens.push([false, 'submb', false, '']) + } + tokens.push(...parseSearchTokens(words.value)) + tokens.push(...parseSearchTokens(from.value).map(t => [t[0], 'f', false, t[3]] as Token)) + tokens.push(...parseSearchTokens(to.value).map(t => [t[0], 't', false, t[3]] as Token)) + const start = makeDateTime(oldestDate.value, oldestTime.value) + if (start) { + tokens.push([false, 'start', false, start]) + } + const end = makeDateTime(newestDate.value, newestTime.value) + if (end) { + tokens.push([false, 'end', false, end]) + } + tokens.push(...parseSearchTokens(subject.value).map(t => [t[0], 's', false, t[3]] as Token)) + const check = (elem: HTMLInputElement, tag: string, value: string): void => { + if (elem.checked) { + tokens.push([false, tag, false, value]) + } + } + check(attachmentNone, 'a', 'none') + check(attachmentAny, 'a', 'any') + check(attachmentImage, 'a', 'image') + check(attachmentPDF, 'a', 'pdf') + check(attachmentArchive, 'a', 'archive') + check(attachmentSpreadsheet, 'a', 'spreadsheet') + check(attachmentDocument, 'a', 'document') + check(attachmentPresentation, 'a', 'presentation') + + tokens.push(...flagViews.filter(fv => fv.active !== null).map(fv => { + return [!fv.active, 'l', false, fv.flag] as Token + })) + tokens.push(...parseSearchTokens(labels.value).map(t => [t[0], 'l', t[2], t[3]] as Token)) + + tokens.push(...headerViews.filter(hv => hv.key.value).map(hv => [false, 'h', false, hv.key.value+':'+hv.value.value] as Token)) + const minstr = parseSearchSize(minsize.value)[0] + if (minstr) { + tokens.push([false, 'minsize', false, minstr]) + } + const maxstr = parseSearchSize(maxsize.value)[0] + if (maxstr) { + tokens.push([false, 'maxsize', false, maxstr]) + } + + searchbarElem.value = tokens.map(packToken).join(' ') + } + + const setDateTime = (s: string | null | undefined, dateElem: HTMLInputElement, timeElem: HTMLInputElement) => { + if (!s) { + return + } + const t = s.split('T', 2) + const dt = t.length === 2 || t[0].includes('-') ? t[0] : '' + const tm = t.length === 2 ? t[1] : (t[0].includes(':') ? t[0] : '') + if (dt) { + dateElem.value = dt + } + if (tm) { + timeElem.value = tm + } + } + + // Update form based on searchbarElem. We parse the searchbarElem into a filter. Then reset + // and populate the form. + const updateForm = (): void => { + const [f, notf, strs] = parseSearch(searchbarElem.value, mailboxlistView) + form.reset() + + const packTwo = (l: string[] | null | undefined, lnot: string[] | null | undefined) => (l || []).map(packString).concat((lnot || []).map(packNotString)).join(' ') + + if (f.MailboxName) { + const o = [...mailbox.options].find(o => o.text === f.MailboxName) || mailbox.options[0] + if (o) { + o.selected = true + } + } else if (f.MailboxID === -1) { + // "All mailboxes except ...". + mailbox.options[0].selected = true + } else { + const id = ''+f.MailboxID + const o = [...mailbox.options].find(o => o.value === id) || mailbox.options[0] + o.selected = true + } + mailboxkids.checked = f.MailboxChildrenIncluded + words.value = packTwo(f.Words, notf.Words) + from.value = packTwo(f.From, notf.From) + to.value = packTwo(f.To, notf.To) + setDateTime(strs.Oldest, oldestDate, oldestTime) + setDateTime(strs.Newest, newestDate, newestTime) + subject.value = packTwo(f.Subject, notf.Subject) + + const elem = (<{[k: string]: HTMLInputElement}>{ + none: attachmentNone, + any: attachmentAny, + image: attachmentImage, + pdf: attachmentPDF, + archive: attachmentArchive, + spreadsheet: attachmentSpreadsheet, + document: attachmentDocument, + presentation: attachmentPresentation, + })[f.Attachments] + if (elem) { + attachmentChecks(elem, true) + } + + const otherlabels: string[] = [] + const othernotlabels: string[] = [] + flagViews.forEach(fv => fv.active = null) + const setLabels = (flabels: string[] | null | undefined, other: string[], not: boolean) => { + (flabels || []).forEach(l => { + l = l.toLowerCase() + // Find if this is a well-known flag. + const fv = flagViews.find(fv => fv.flag.toLowerCase() === l) + if (fv) { + fv.active = !not + fv.update() + } else { + other.push(l) + } + }) + } + setLabels(f.Labels, otherlabels, false) + setLabels(notf.Labels, othernotlabels, true) + labels.value = packTwo(otherlabels, othernotlabels) + + headerViews.slice(1).forEach(hv => hv.root.remove()) + headerViews = [headerViews[0]] + if (f.Headers && f.Headers.length > 0) { + (f.Headers || []).forEach((kv, index) => { + const [k, v] = kv || ['', ''] + if (index > 0) { + addHeaderView() + } + headerViews[index].key.value = k + headerViews[index].value.value = v + }) + } + + if (strs.SizeMin) { + minsize.value = strs.SizeMin + } + if (strs.SizeMax) { + maxsize.value = strs.SizeMax + } + } + + const attachmentChecks = (elem: HTMLInputElement, set?: boolean): void => { + if (elem.checked || set) { + for (const e of [attachmentNone, attachmentAny, attachmentImage, attachmentPDF, attachmentArchive, attachmentSpreadsheet, attachmentDocument, attachmentPresentation]) { + if (e !== elem) { + e.checked = false + } else if (set) { + e.checked = true + } + } + } + } + + const changeHandlers = [ + function change() { + updateSearchbar() + }, + function keyup() { + updateSearchbar() + }, + ] + + const attachmentHandlers = [ + function change(e: Event) { + attachmentChecks(e.target! as HTMLInputElement) + }, + function mousedown(e: MouseEvent) { + // Radiobuttons cannot be deselected normally. With this handler a user can push + // down on the button, then move pointer out of button and release the button to + // clear the radiobutton. + const target = e.target! as HTMLInputElement + if (e.buttons === 1 && target.checked) { + target.checked = false + e.preventDefault() + } + }, + ...changeHandlers, + ] + + interface HeaderView { + root: HTMLElement, + key: HTMLInputElement, + value: HTMLInputElement, + } + + let headersCell: HTMLElement // Where we add headerViews. + let headerViews: HeaderView[] + + const newHeaderView = (first: boolean) => { + let key: HTMLInputElement, value: HTMLInputElement + const root = dom.div( + style({display: 'flex'}), + key=dom.input(focusPlaceholder('Header name'), style({width: '40%'}), changeHandlers), + dom.div(style({width: '.5em'})), + value=dom.input(focusPlaceholder('Header value'), style({flexGrow: 1}), changeHandlers), + dom.div( + style({width: '2.5em', paddingLeft: '.25em'}), + dom.clickbutton('+', style({padding: '0 .25em'}), attr.arialabel('Add row for another header filter.'), attr.title('Add row for another header filter.'), function click() { + addHeaderView() + }), + ' ', + first ? [] : dom.clickbutton('-', style({padding: '0 .25em'}), attr.arialabel('Remove row.'), attr.title('Remove row.'), function click() { + root.remove() + const index = headerViews.findIndex(v => v === hv) + headerViews.splice(index, 1) + updateSearchbar() + }), + ), + ) + const hv: HeaderView = {root: root, key: key, value: value} + return hv + } + + const addHeaderView = (): void => { + const hv = newHeaderView(false) + headersCell.appendChild(hv.root) + headerViews.push(hv) + } + + const setPeriod = (d: Date): void => { + newestDate.value = '' + newestTime.value = '' + const pad0 = (v: number) => v <= 9 ? '0'+v : ''+v + const dt = [d.getFullYear(), pad0(d.getMonth()+1), pad0(d.getDate())].join('-') + const tm = ''+pad0(d.getHours())+':'+pad0(d.getMinutes()) + oldestDate.value = dt + oldestTime.value = tm + updateSearchbar() + } + + const root = dom.div( + style({position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', zIndex: zindexes.compose}), + function click(e: MouseEvent) { + e.stopPropagation() + searchViewClose() + }, + function keyup(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + searchViewClose() + } + }, + dom.search( + style({position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: 'white', boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', borderRadius: '.15em'}), + function click(e: MouseEvent) { + e.stopPropagation() + }, + // This is a separate form, inside the form with the overall search field because + // when updating the form based on the parsed searchbar, we first need to reset it. + form=dom.form( + dom.table(dom._class('search'), style({width: '100%'}), + dom.tr( + dom.td(dom.label('Mailbox', attr.for('searchMailbox')), attr.title('Filter by mailbox, including children of the mailbox.')), + dom.td( + mailbox=dom.select(attr.id('searchMailbox'), style({width: '100%'}), + dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), + dom.option('All mailboxes', attr.value('0')), + changeHandlers, + ), + dom.div(style({paddingTop: '.5ex'}), dom.label(mailboxkids=dom.input(attr.type('checkbox'), changeHandlers), ' Also search in mailboxes below the selected mailbox.')), + ), + ), + dom.tr( + dom.td(dom.label('Text', attr.for('searchWords'))), + dom.td( + words=dom.input(attr.id('searchWords'), attr.title('Filter by text, case-insensitive, substring match, not necessarily whole words.'), focusPlaceholder('word "exact match" -notword'), style({width: '100%'}), changeHandlers), + ), + ), + dom.tr( + dom.td(dom.label('From', attr.for('searchFrom'))), + dom.td( + from=dom.input(attr.id('searchFrom'), style({width: '100%'}), focusPlaceholder('Address or name'), newAddressComplete(), changeHandlers) + ), + ), + dom.tr( + dom.td(dom.label('To', attr.for('searchTo')), attr.title('Search on addressee, including Cc and Bcc headers.')), + dom.td( + to=dom.input(attr.id('searchTo'), focusPlaceholder('Address or name, also matches Cc and Bcc addresses'), style({width: '100%'}), newAddressComplete(), changeHandlers), + ), + ), + dom.tr( + dom.td(dom.label('Search', attr.for('searchSubject'))), + dom.td( + subject=dom.input(attr.id('searchSubject'), style({width: '100%'}), focusPlaceholder('"exact match"'), changeHandlers) + ), + ), + dom.tr( + dom.td('Received between', style({whiteSpace: 'nowrap'})), + dom.td( + style({lineHeight: 2}), + dom.div( + oldestDate=dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), + oldestTime=dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), + ' ', + dom.clickbutton('x', style({padding: '0 .3em'}), attr.arialabel('Clear start date.'), attr.title('Clear start date.'), function click() { + oldestDate.value = '' + oldestTime.value = '' + updateSearchbar() + }), + ' and ', + newestDate=dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), + newestTime=dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), + ' ', + dom.clickbutton('x', style({padding: '0 .3em'}), attr.arialabel('Clear end date.'), attr.title('Clear end date.'), function click() { + newestDate.value = '' + newestTime.value = '' + updateSearchbar() + }), + ), + dom.div( + dom.clickbutton('1 day', function click() { + setPeriod(new Date(new Date().getTime() - 24*3600*1000)) + }), + ' ', + dom.clickbutton('1 week', function click() { + setPeriod(new Date(new Date().getTime() - 7*24*3600*1000)) + }), + ' ', + dom.clickbutton('1 month', function click() { + setPeriod(new Date(new Date().getTime() - 31*24*3600*1000)) + }), + ' ', + dom.clickbutton('1 year', function click() { + setPeriod(new Date(new Date().getTime() - 365*24*3600*1000)) + }), + ), + ), + ), + dom.tr( + dom.td('Attachments'), + dom.td( + dom.label(style({whiteSpace: 'nowrap'}), attachmentNone=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('none'), attachmentHandlers), ' None'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentAny=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('any'), attachmentHandlers), ' Any'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentImage=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('image'), attachmentHandlers), ' Images'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentPDF=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('pdf'), attachmentHandlers), ' PDFs'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentArchive=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('archive'), attachmentHandlers), ' Archives'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentSpreadsheet=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('spreadsheet'), attachmentHandlers), ' Spreadsheets'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentDocument=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('document'), attachmentHandlers), ' Documents'), ' ', + dom.label(style({whiteSpace: 'nowrap'}), attachmentPresentation=dom.input(attr.type('radio'), attr.name('attachments'), attr.value('presentation'), attachmentHandlers), ' Presentations'), ' ', + ), + ), + dom.tr( + dom.td('Labels'), + dom.td( + style({lineHeight: 2}), + join(flagViews=Object.entries({Read: '\\Seen', Replied: '\\Answered', Flagged: '\\Flagged', Deleted: '\\Deleted', Draft: '\\Draft', Forwarded: '$Forwarded', Junk: '$Junk', NotJunk: '$NotJunk', Phishing: '$Phishing', MDNSent: '$MDNSent'}).map(t => { + const [name, flag] = t + const v: FlagView = { + active: null, + flag: flag, + root: dom.clickbutton(name, function click() { + if (v.active === null) { + v.active = true + } else if (v.active === true) { + v.active = false + } else { + v.active = null + } + v.update() + updateSearchbar() + }), + update: () => { + v.root.style.backgroundColor = v.active === true ? '#c4ffa9' : (v.active === false ? '#ffb192' : '') + }, + } + return v + }), () => ' '), + ' ', + labels=dom.input(focusPlaceholder('todo -done "-dashingname"'), attr.title('User-defined labels.'), changeHandlers), + ), + ), + dom.tr( + dom.td('Headers'), + headersCell=dom.td(headerViews=[newHeaderView(true)]), + ), + dom.tr( + dom.td('Size between'), + dom.td( + minsize=dom.input(style({width: '6em'}), focusPlaceholder('10kb'), changeHandlers), + ' and ', + maxsize=dom.input(style({width: '6em'}), focusPlaceholder('1mb'), changeHandlers), + ), + ), + ), + dom.div( + style({padding: '1ex', textAlign: 'right'}), + dom.submitbutton('Search'), + ), + async function submit(e: SubmitEvent) { + e.preventDefault() + await searchView.submit() + }, + ), + ), + ) + + const submit = async (): Promise => { + const [f, notf, _] = parseSearch(searchbarElem.value, mailboxlistView) + await startSearch(f, notf) + } + + let loaded = false + + const searchView: SearchView = { + root: root, + submit: submit, + ensureLoaded: () => { + if (loaded || mailboxlistView.mailboxes().length === 0) { + return + } + loaded = true + dom._kids(mailbox, + dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), + dom.option('All mailboxes', attr.value('0')), + mailboxlistView.mailboxes().map(mb => dom.option(mb.Name, attr.value(''+mb.ID))), + ) + searchView.updateForm() + }, + updateForm: updateForm, + } + return searchView +} + +// Functions we pass to various views, to access functionality encompassing all views. +type requestNewView = (clearMsgID: boolean, filterOpt?: api.Filter, notFilterOpt?: api.NotFilter) => Promise +type updatePageTitle = () => void +type setLocationHash = () => void +type unloadSearch = () => void +type otherMailbox = (mailboxID: number) => api.Mailbox | null +type possibleLabels = () => string[] +type listMailboxes = () => api.Mailbox[] + +const init = async () => { + let connectionElem: HTMLElement // SSE connection status/error. Empty when connected. + let layoutElem: HTMLSelectElement // Select dropdown for layout. + + let msglistscrollElem: HTMLElement + let queryactivityElem: HTMLElement // We show ... when a query is active and data is forthcoming. + + // Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate. + const listendElem = dom.div(style({borderTop: '1px solid #ccc', color: '#666', margin: '1ex'})) + const listloadingElem = dom.div(style({textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8'}), 'loading...') + const listerrElem = dom.div(style({textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8'})) + + let sseID = 0 // Sent by server in initial SSE response. We use it in API calls to make the SSE endpoint return new data we need. + let viewSequence = 0 // Counter for assigning viewID. + let viewID = 0 // Updated when a new view is started, e.g. when opening another mailbox or starting a search. + let search = { + active: false, // Whether a search is active. + query: '', // The query, as shown in the searchbar. Used in location hash. + } + let requestSequence = 0 // Counter for assigning requestID. + let requestID = 0 // Current request, server will mirror it in SSE data. If we get data for a different id, we ignore it. + let requestViewEnd = false // If true, there is no more data to fetch, no more page needed for this view. + let requestFilter = newFilter() + let requestNotFilter = newNotFilter() + let requestMsgID = 0 // If > 0, we are still expecting a parsed message for the view, coming from the query. Either we get it and set msgitemViewActive and clear this, or we get to the end of the data and clear it. + + const updatePageTitle = () => { + const mb = mailboxlistView && mailboxlistView.activeMailbox() + const addr = loginAddress ? loginAddress.User+'@'+(loginAddress.Domain.Unicode || loginAddress.Domain.ASCII) : '' + if (!mb) { + document.title = [addr, 'Mox Webmail'].join(' - ') + } else { + document.title = ['('+mb.Unread+') '+mb.Name, addr, 'Mox Webmail'].join(' - ') + } + } + + const setLocationHash = () => { + const msgid = requestMsgID || msglistView.activeMessageID() + const msgidstr = msgid ? ','+msgid : '' + let hash + const mb = mailboxlistView && mailboxlistView.activeMailbox() + if (mb) { + hash = '#'+mb.Name + msgidstr + } else if (search.active) { + hash = '#search ' + search.query + msgidstr + } else { + hash = '#' + } + // We need to set the full URL or we would get errors about insecure operations for + // plain http with firefox. + const l = window.location + const url = l.protocol + '//' + l.host + l.pathname + l.search + hash + window.history.replaceState(undefined, '', url) + } + + const loadSearch = (q: string) => { + search = {active: true, query: q} + searchbarElem.value = q + searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)' // Cleared when another view is loaded. + searchbarElemBox.style.flexGrow = '4' + } + const unloadSearch = () => { + searchbarElem.value = '' + searchbarElem.style.background = '' + searchbarElem.style.zIndex = '' + searchbarElemBox.style.flexGrow = '' // Make search bar smaller again. + search = {active: false, query: ''} + searchView.root.remove() + } + const clearList = () => { + msglistView.clear() + listendElem.remove() + listloadingElem.remove() + listerrElem.remove() + } + + const requestNewView = async (clearMsgID: boolean, filterOpt?: api.Filter, notFilterOpt?: api.NotFilter) => { + if (!sseID) { + throw new Error('not connected') + } + + if (clearMsgID) { + requestMsgID = 0 + } + + msglistView.root.classList.toggle('loading', true) + clearList() + + viewSequence++ + viewID = viewSequence + if (filterOpt) { + requestFilter = filterOpt + requestNotFilter = notFilterOpt || newNotFilter() + } + + requestViewEnd = false + const bounds = msglistscrollElem.getBoundingClientRect() + await requestMessages(bounds, 0, requestMsgID) + } + + const requestMessages = async (scrollBounds: DOMRect, anchorMessageID: number, destMessageID: number) => { + const fetchCount = Math.max(50, 3*Math.ceil(scrollBounds.height/msglistView.itemHeight())) + const page = { + AnchorMessageID: anchorMessageID, + Count: fetchCount, + DestMessageID: destMessageID, + } + requestSequence++ + requestID = requestSequence + const [f, notf] = refineFilters(requestFilter, requestNotFilter) + const query = { + OrderAsc: settings.orderAsc, + Filter: f, + NotFilter: notf, + } + const request = { + ID: requestID, + SSEID: sseID, + ViewID: viewID, + Cancel: false, + Query: query, + Page: page, + } + dom._kids(queryactivityElem, 'loading...') + msglistscrollElem.appendChild(listloadingElem) + await client.Request(request) + } + + // msgElem can show a message, show actions on multiple messages, or be empty. + let msgElem = dom.div( + style({position: 'absolute', right: 0, left: 0, top: 0, bottom: 0}), + style({backgroundColor: '#f8f8f8'}), + ) + + // Returns possible labels based, either from active mailbox (possibly from search), or all mailboxes. + const possibleLabels = (): string[] => { + if (requestFilter.MailboxID > 0) { + const mb = mailboxlistView.findMailboxByID(requestFilter.MailboxID) + if (mb) { + return mb.Keywords || [] + } + } + const all: {[key: string]: undefined} = {} + mailboxlistView.mailboxes().forEach(mb => { + for (const k of (mb.Keywords || [])) { + all[k] = undefined + } + }) + const l = Object.keys(all) + l.sort() + return l + } + + const refineKeyword = async (kw: string) => { + settingsPut({...settings, refine: 'label:'+kw}) + refineToggleActive(refineLabelBtn as HTMLButtonElement) + dom._kids(refineLabelBtn, 'Label: '+kw) + await withStatus('Requesting messages', requestNewView(false)) + } + + const otherMailbox = (mailboxID: number): api.Mailbox | null => requestFilter.MailboxID !== mailboxID ? (mailboxlistView.findMailboxByID(mailboxID) || null) : null + const listMailboxes = () => mailboxlistView.mailboxes() + const msglistView = newMsglistView(msgElem, listMailboxes, setLocationHash, otherMailbox, possibleLabels, () => msglistscrollElem ? msglistscrollElem.getBoundingClientRect().height : 0, refineKeyword) + const mailboxlistView = newMailboxlistView(msglistView, requestNewView, updatePageTitle, setLocationHash, unloadSearch) + + let refineUnreadBtn: HTMLButtonElement, refineReadBtn: HTMLButtonElement, refineAttachmentsBtn: HTMLButtonElement, refineLabelBtn: HTMLButtonElement + const refineToggleActive = (btn: HTMLButtonElement | null): void => { + for (const e of [refineUnreadBtn, refineReadBtn, refineAttachmentsBtn, refineLabelBtn]) { + e.classList.toggle('active', e === btn) + } + if (btn !== null && btn !== refineLabelBtn) { + dom._kids(refineLabelBtn, 'Label') + } + } + + let msglistElem = dom.div(dom._class('msglist'), + style({position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column'}), + dom.div( + attr.role('region'), attr.arialabel('Filter and sorting buttons for message list'), + style({display: 'flex', justifyContent: 'space-between', backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', padding: '.25em .5em'}), + dom.div( + dom.h1('Refine:', style({fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0}), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), + ' ', + dom.span(dom._class('btngroup'), + refineUnreadBtn=dom.clickbutton(settings.refine === 'unread' ? dom._class('active') : [], + 'Unread', + attr.title('Only show messages marked as unread.'), + async function click(e: MouseEvent) { + settingsPut({...settings, refine: 'unread'}) + refineToggleActive(e.target! as HTMLButtonElement) + await withStatus('Requesting messages', requestNewView(false)) + }, + ), + refineReadBtn=dom.clickbutton(settings.refine === 'read' ? dom._class('active') : [], + 'Read', + attr.title('Only show messages marked as read.'), + async function click(e: MouseEvent) { + settingsPut({...settings, refine: 'read'}) + refineToggleActive(e.target! as HTMLButtonElement) + await withStatus('Requesting messages', requestNewView(false)) + }, + ), + refineAttachmentsBtn=dom.clickbutton(settings.refine === 'attachments' ? dom._class('active') : [], + 'Attachments', + attr.title('Only show messages with attachments.'), + async function click(e: MouseEvent) { + settingsPut({...settings, refine: 'attachments'}) + refineToggleActive(e.target! as HTMLButtonElement) + await withStatus('Requesting messages', requestNewView(false)) + }, + ), + refineLabelBtn=dom.clickbutton(settings.refine.startsWith('label:') ? [dom._class('active'), 'Label: '+settings.refine.substring('label:'.length)] : 'Label', + attr.title('Only show messages with the selected label.'), + async function click(e: MouseEvent) { + const labels = possibleLabels() + const remove = popover(e.target! as HTMLElement, {}, + dom.div( + style({display: 'flex', flexDirection: 'column', gap: '1ex'}), + labels.map(l => { + const selectLabel = async () => { + settingsPut({...settings, refine: 'label:'+l}) + refineToggleActive(e.target! as HTMLButtonElement) + dom._kids(refineLabelBtn, 'Label: '+l) + await withStatus('Requesting messages', requestNewView(false)) + remove() + } + return dom.div( + dom.clickbutton(dom._class('keyword'), l, async function click() { + await selectLabel() + }), + ) + }), + labels.length === 0 ? dom.div('No labels yet, set one on a message first.') : [], + ) + ) + }, + ), + ), + ' ', + dom.clickbutton( + 'x', + style({padding: '0 .25em'}), + attr.arialabel('Clear refinement filters'), + attr.title('Clear refinement filters.'), + async function click(e: MouseEvent) { + settingsPut({...settings, refine: ''}) + refineToggleActive(e.target! as HTMLButtonElement) + await withStatus('Requesting messages', requestNewView(false)) + }, + ), + ), + dom.div( + queryactivityElem=dom.span(), + ' ', + dom.clickbutton('↑↓', attr.title('Toggle sorting by date received.'), settings.orderAsc ? dom._class('invert') : [], async function click(e: MouseEvent) { + settingsPut({...settings, orderAsc: !settings.orderAsc}) + ;(e.target! as HTMLButtonElement).classList.toggle('invert', settings.orderAsc) + // We don't want to include the currently selected message because it could cause a + // huge amount of messages to be fetched. e.g. when first message in large mailbox + // was selected, it would now be the last message. + await withStatus('Requesting messages', requestNewView(true)) + }), + ), + ), + dom.div( + style({height: '1ex', position: 'relative'}), + dom.div(dom._class('msgitemflags')), + dom.div(dom._class('msgitemflagsoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect() + const width = Math.round(e.clientX - bounds.x) + settingsPut({...settings, msglistflagsWidth: width}) + updateMsglistWidths() + }) + } + ), + dom.div(dom._class('msgitemfrom')), + dom.div(dom._class('msgitemfromoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect() + const x = Math.round(e.clientX - bounds.x - lastflagswidth) + const width = bounds.width - lastflagswidth - lastagewidth + const pct = 100*x/width + settingsPut({...settings, msglistfromPct: pct}) + updateMsglistWidths() + }) + } + ), + dom.div(dom._class('msgitemsubject')), + dom.div(dom._class('msgitemsubjectoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + const bounds = msglistscrollElem.getBoundingClientRect() + const width = Math.round(bounds.x+bounds.width - e.clientX) + settingsPut({...settings, msglistageWidth: width}) + updateMsglistWidths() + }) + } + ), + dom.div(dom._class('msgitemage')), + ), + dom.div( + style({flexGrow: '1', position: 'relative'}), + msglistscrollElem=dom.div(dom._class('yscroll'), + attr.role('region'), attr.arialabel('Message list'), + async function scroll() { + if (!sseID || requestViewEnd || requestID) { + return + } + + // We know how many entries we have, and how many screenfulls. So we know when we + // only have 2 screen fulls left. That's when we request the next data. + const bounds = msglistscrollElem.getBoundingClientRect() + if (msglistscrollElem.scrollTop < msglistscrollElem.scrollHeight-3*bounds.height) { + return + } + + // log('new request for scroll') + const reqAnchor = msglistView.anchorMessageID() + await withStatus('Requesting more messages', requestMessages(bounds, reqAnchor, 0)) + }, + dom.div( + style({width: '100%', borderSpacing: '0'}), + msglistView, + ), + ), + ), + ) + + let searchbarElem: HTMLInputElement // Input field for search + + // Called by searchView when user executes the search. + const startSearch = async (f: api.Filter, notf: api.NotFilter): Promise => { + if (!sseID) { + window.alert('Error: not connect') + return + } + + // If search has an attachment filter, clear it from the quick filter or we will + // confuse the user with no matches. The refinement would override the selection. + if (f.Attachments !== '' && settings.refine === 'attachments') { + settingsPut({...settings, refine: ''}) + refineToggleActive(null) + } + search = {active: true, query: searchbarElem.value} + mailboxlistView.closeMailbox() + setLocationHash() + searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)' // Cleared when another view is loaded. + searchView.root.remove() + searchbarElem.blur() + document.body.focus() + await withStatus('Requesting messages', requestNewView(true, f, notf)) + } + + // Called by searchView when it is closed, due to escape key or click on background. + const searchViewClose = () => { + if (!search.active) { + unloadSearch() + } else { + searchbarElem.value = search.query + searchView.root.remove() + } + } + + // For dragging. + let mailboxesElem: HTMLElement, topcomposeboxElem: HTMLElement, mailboxessplitElem: HTMLElement + let splitElem: HTMLElement + + let searchbarElemBox: HTMLElement // Detailed search form, opened when searchbarElem gets focused. + + const searchbarInitial = () => { + const mailboxActive = mailboxlistView.activeMailbox() + if (mailboxActive && mailboxActive.Name !== 'Inbox') { + return packToken([false, 'mb', false, mailboxActive.Name]) + ' ' + } + return '' + } + + const ensureSearchView = () => { + if (searchView.root.parentElement) { + // Already open. + return + } + searchView.ensureLoaded() + const pos = searchbarElem.getBoundingClientRect() + const child = searchView.root.firstChild! as HTMLElement + child.style.left = ''+pos.x+'px' + child.style.top = ''+(pos.y+pos.height+2)+'px' + // Append to just after search input so next tabindex is at form. + searchbarElem.parentElement!.appendChild(searchView.root) + + // Make search bar as wide as possible. Made smaller when searchView is hidden again. + searchbarElemBox.style.flexGrow = '4' + + searchbarElem.style.zIndex = zindexes.searchbar + } + + const cmdSearch = async () => { + searchbarElem.focus() + if (!searchbarElem.value) { + searchbarElem.value = searchbarInitial() + } + ensureSearchView() + searchView.updateForm() + } + + const cmdCompose = async () => { compose({}) } + const cmdOpenInbox = async () => { + const mb = mailboxlistView.findMailboxByName('Inbox') + if (mb) { + await mailboxlistView.openMailboxID(mb.ID, true) + const f = newFilter() + f.MailboxID = mb.ID + await withStatus('Requesting messages', requestNewView(true, f, newNotFilter())) + } + } + const cmdFocusMsg = async() => { + const btn = msgElem.querySelector('button') + if (btn && btn instanceof HTMLElement) { + btn.focus() + } + } + + const shortcuts: {[key: string]: command} = { + i: cmdOpenInbox, + '/': cmdSearch, + '?': cmdHelp, + 'ctrl ?': cmdTooltip, + c: cmdCompose, + M: cmdFocusMsg, + } + + const webmailroot = dom.div( + style({display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh'}), + dom.div(dom._class('topbar'), + style({display: 'flex'}), + attr.role('region'), attr.arialabel('Top bar'), + topcomposeboxElem=dom.div(dom._class('pad'), + style({width: settings.mailboxesWidth + 'px', textAlign: 'center'}), + dom.clickbutton('Compose', attr.title('Compose new email message.'), function click() { + shortcutCmd(cmdCompose, shortcuts) + }), + ), + dom.div(dom._class('pad'), + style({paddingLeft: 0, display: 'flex', flexGrow: 1}), + searchbarElemBox=dom.search( + style({display: 'flex', marginRight: '.5em'}), + dom.form( + style({display: 'flex', flexGrow: 1}), + searchbarElem=dom.input( + attr.placeholder('Search...'), + style({position: 'relative', width: '100%'}), + attr.title('Search messages based on criteria like matching free-form text, in a mailbox, labels, addressees.'), + focusPlaceholder('word "with space" -notword mb:Inbox f:from@x.example t:rcpt@x.example start:2023-7-1 end:2023-7-8 s:"subject" a:images l:$Forwarded h:Reply-To:other@x.example minsize:500kb'), + function click() { + cmdSearch() + showShortcut('/') + }, + function focus() { + // Make search bar as wide as possible. Made smaller when searchView is hidden again. + searchbarElemBox.style.flexGrow = '4' + if (!searchbarElem.value) { + searchbarElem.value = searchbarInitial() + } + }, + function blur() { + if (searchbarElem.value === searchbarInitial()) { + searchbarElem.value = '' + } + if (!search.active) { + searchbarElemBox.style.flexGrow = '' + } + }, + function change() { + searchView.updateForm() + }, + function keyup(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + searchViewClose() + return + } + if (searchbarElem.value && searchbarElem.value !== searchbarInitial()) { + ensureSearchView() + } + searchView.updateForm() + }, + ), + dom.clickbutton('x', attr.arialabel('Cancel and clear search.'), attr.title('Cancel and clear search.'), style({marginLeft: '.25em', padding: '0 .3em'}), async function click() { + searchbarElem.value = '' + if (!search.active) { + return + } + clearList() + unloadSearch() + updatePageTitle() + setLocationHash() + if (requestID) { + requestSequence++ + requestID = requestSequence + const query = { + OrderAsc: settings.orderAsc, + Filter: newFilter(), + NotFilter: newNotFilter() + } + const page = {AnchorMessageID: 0, Count: 0, DestMessageID: 0} + const request = { + ID: requestID, + SSEID: sseID, + ViewID: viewID, + Cancel: true, + Query: query, + Page: page, + } + dom._kids(queryactivityElem) + await withStatus('Canceling query', client.Request(request)) + } else { + dom._kids(queryactivityElem) + } + }), + async function submit(e: SubmitEvent) { + e.preventDefault() + await searchView.submit() + }, + ), + ), + connectionElem=dom.div(), + statusElem=dom.div(style({marginLeft: '.5em', flexGrow: '1'}), attr.role('status')), + dom.div( + style({paddingLeft: '1em'}), + layoutElem=dom.select( + attr.title('Layout of message list and message panes. Top/bottom has message list above message view. Left/Right has message list left, message view right. Auto selects based on window width and automatically switches on resize. Wide screens get left/right, smaller screens get top/bottom.'), + dom.option('Auto layout', attr.value('auto'), settings.layout === 'auto' ? attr.selected('') : []), + dom.option('Top/bottom', attr.value('topbottom'), settings.layout === 'topbottom' ? attr.selected('') : []), + dom.option('Left/right', attr.value('leftright'), settings.layout === 'leftright' ? attr.selected('') : []), + function change() { + settingsPut({...settings, layout: layoutElem.value}) + if (layoutElem.value === 'auto') { + autoselectLayout() + } else { + selectLayout(layoutElem.value) + } + }, + ), ' ', + dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), + ' ', + dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), + ' ', + link('https://github.com/mjl-/mox', 'mox'), + ), + ), + ), + dom.div( + style({flexGrow: '1'}), + style({position: 'relative'}), + mailboxesElem=dom.div(dom._class('mailboxesbar'), + style({position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0}), + style({display: 'flex', flexDirection: 'column', alignContent: 'stretch'}), + dom.div(dom._class('pad', 'yscrollauto'), + style({flexGrow: '1'}), + style({position: 'relative'}), + mailboxlistView.root, + ), + ), + mailboxessplitElem=dom.div( + style({position: 'absolute', left: 'calc('+settings.mailboxesWidth +'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), + dom.div( + style({position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa'}), + ), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + mailboxesElem.style.width = Math.round(e.clientX)+'px' + topcomposeboxElem.style.width = Math.round(e.clientX)+'px' + mailboxessplitElem.style.left = 'calc('+e.clientX+'px - 2px)' + splitElem.style.left = 'calc('+e.clientX+'px + 1px)' + settingsPut({...settings, mailboxesWidth: Math.round(e.clientX)}) + }) + } + ), + splitElem=dom.div(style({position: 'absolute', left: 'calc(' + settings.mailboxesWidth+'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid #bbb'})), + ), + ) + + // searchView is shown when search gets focus. + const searchView = newSearchView(searchbarElem, mailboxlistView, startSearch, searchViewClose) + + document.body.addEventListener('keydown', async (e: KeyboardEvent) => { + // Don't do anything for just the press of the modifiers. + switch (e.key) { + case 'OS': + case 'Control': + case 'Shift': + case 'Alt': + return + } + + // Popup have their own handlers, e.g. for scrolling. + if (popupOpen) { + return + } + + // Prevent many regular key presses from being processed, some possibly unintended. + if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) { + // log('skipping key without modifiers on input/textarea') + return + } + let l = [] + if (e.ctrlKey) { + l.push('ctrl') + } + if (e.altKey) { + l.push('alt') + } + if (e.metaKey) { + l.push('meta') + } + l.push(e.key) + const k = l.join(' ') + + if (composeView) { + await composeView.key(k, e) + return + } + const cmdfn = shortcuts[k] + if (cmdfn) { + e.preventDefault() + e.stopPropagation() + await cmdfn() + return + } + msglistView.key(k, e) + }) + + let currentLayout: string = '' + + const selectLayout = (want: string) => { + if (want === currentLayout) { + return + } + + if (want === 'leftright') { + let left: HTMLElement, split: HTMLElement, right: HTMLElement + dom._kids(splitElem, + left=dom.div( + style({position: 'absolute', left: 0, width: 'calc(' + settings.leftWidthPct + '% - 1px)', top: 0, bottom: 0}), + msglistElem, + ), + split=dom.div( + style({position: 'absolute', left: 'calc(' + settings.leftWidthPct + '% - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), + dom.div(style({position: 'absolute', backgroundColor: '#aaa', top: 0, bottom: 0, width: '1px', left: '2px', right: '2px'})), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + const bounds = left.getBoundingClientRect() + const x = Math.round(e.clientX - bounds.x) + left.style.width = 'calc(' + x +'px - 1px)' + split.style.left = 'calc(' + x +'px - 2px)' + right.style.left = 'calc(' + x+'px + 1px)' + settingsPut({...settings, leftWidthPct: Math.round(100*bounds.width/splitElem.getBoundingClientRect().width)}) + updateMsglistWidths() + }) + } + ), + right=dom.div( + style({position: 'absolute', right: 0, left: 'calc(' + settings.leftWidthPct + '% + 1px)', top: 0, bottom: 0}), + msgElem, + ), + ) + } else { + let top: HTMLElement, split: HTMLElement, bottom: HTMLElement + dom._kids(splitElem, + top=dom.div( + style({position: 'absolute', top: 0, height: 'calc(' + settings.topHeightPct + '% - 1px)', left: 0, right: 0}), + msglistElem, + ), + split=dom.div( + style({position: 'absolute', top: 'calc(' + settings.topHeightPct + '% - 2px)', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter}), + dom.div(style({position: 'absolute', backgroundColor: '#aaa', left: 0, right: 0, height: '1px', top: '2px', bottom: '2px'})), + function mousedown(e: MouseEvent) { + startDrag(e, (e) => { + const bounds = top.getBoundingClientRect() + const y = Math.round(e.clientY - bounds.y) + top.style.height = 'calc(' + y + 'px - 1px)' + split.style.top = 'calc(' + y + 'px - 2px)' + bottom.style.top = 'calc(' + y +'px + 1px)' + settingsPut({...settings, topHeightPct: Math.round(100*bounds.height/splitElem.getBoundingClientRect().height)}) + }) + } + ), + bottom=dom.div( + style({position: 'absolute', bottom: 0, top: 'calc(' + settings.topHeightPct + '% + 1px)', left: 0, right: 0}), + msgElem, + ), + ) + } + currentLayout = want + checkMsglistWidth() + } + + const autoselectLayout = () => { + const want = window.innerWidth <= 2*2560/3 ? 'topbottom' : 'leftright' + selectLayout(want) + } + + // When the window size or layout changes, we recalculate the desired widths for + // the msglist "table". It is a list of divs, each with flex layout with 4 elements + // of fixed size. + // Cannot use the CSSStyleSheet constructor with its replaceSync method because + // safari only started implementing it in 2023q1. So we do it the old-fashioned + // way, inserting a style element and updating its style. + const styleElem = dom.style(attr.type('text/css')) + document.head.appendChild(styleElem) + const stylesheet = styleElem.sheet! + + let lastmsglistwidth = -1 + const checkMsglistWidth = () => { + const width = msglistscrollElem.getBoundingClientRect().width + if (lastmsglistwidth === width || width <= 0) { + return + } + + updateMsglistWidths() + } + let lastflagswidth: number, lastagewidth: number + let rulesInserted = false + const updateMsglistWidths = () => { + const width = msglistscrollElem.clientWidth + lastmsglistwidth = width + + let flagswidth = settings.msglistflagsWidth + let agewidth = settings.msglistageWidth + let frompct = settings.msglistfromPct // Of remaining space. + if (flagswidth + agewidth > width) { + flagswidth = Math.floor(width/2) + agewidth = width-flagswidth + } + const remain = width - (flagswidth+agewidth) + const fromwidth = Math.floor(frompct * remain / 100) + const subjectwidth = Math.floor(remain - fromwidth) + const cssRules: [string, {[style: string]: number}][] = [ + ['.msgitemflags', {width: flagswidth}], + ['.msgitemfrom', {width: fromwidth}], + ['.msgitemsubject', {width: subjectwidth}], + ['.msgitemage', {width: agewidth}], + ['.msgitemflagsoffset', {left: flagswidth}], + ['.msgitemfromoffset', {left: flagswidth + fromwidth}], + ['.msgitemsubjectoffset', {left: flagswidth + fromwidth + subjectwidth}], + ] + if (!rulesInserted) { + cssRules.forEach((rule, i) => { stylesheet.insertRule(rule[0] + '{}', i) }) + rulesInserted = true + } + cssRules.forEach((rule, i) => { + const r = stylesheet.cssRules[i] as CSSStyleRule + for (const k in rule[1]) { + r.style.setProperty(k, ''+rule[1][k]+'px') + } + }) + lastflagswidth = flagswidth + lastagewidth = agewidth + } + + // Select initial layout. + if (layoutElem.value === 'auto') { + autoselectLayout() + } else { + selectLayout(layoutElem.value) + } + dom._kids(page, webmailroot) + checkMsglistWidth() + + window.addEventListener('resize', function() { + if (layoutElem.value === 'auto') { + autoselectLayout() + } + checkMsglistWidth() + }) + + window.addEventListener('hashchange', async () => { + const [search, msgid, f, notf] = parseLocationHash(mailboxlistView) + + requestMsgID = msgid + if (search) { + mailboxlistView.closeMailbox() + loadSearch(search) + } else { + unloadSearch() + await mailboxlistView.openMailboxID(f.MailboxID, false) + } + await withStatus('Requesting messages', requestNewView(false, f, notf)) + }) + + + let eventSource: EventSource | null = null // If set, we have a connection. + let connecting = false // Check before reconnecting. + let noreconnect = false // Set after one reconnect attempt fails. + let noreconnectTimer = 0 // Timer ID for resetting noreconnect. + + // Don't show disconnection just before user navigates away. + let leaving = false + window.addEventListener('beforeunload', () => { + leaving = true + if (eventSource) { + eventSource.close() + eventSource = null + sseID = 0 + } + }) + + // On chromium, we may get restored when user hits the back button ("bfcache"). We + // have left, closed the connection, so we should restore it. + window.addEventListener('pageshow', async (e: PageTransitionEvent) => { + if (e.persisted && !eventSource && !connecting) { + noreconnect = false + connect(false) + } + }) + + // If user comes back to tab/window, and we are disconnected, try another reconnect. + window.addEventListener('focus', () => { + if (!eventSource && !connecting) { + noreconnect = false + connect(true) + } + }) + + const showNotConnected = () => { + dom._kids(connectionElem, + attr.role('status'), + dom.span(style({backgroundColor: '#ffa9a9', padding: '0 .15em', borderRadius: '.15em'}), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), + ' ', + dom.clickbutton('Reconnect', function click() { + if (!eventSource && !connecting) { + noreconnect = false + connect(true) + } + }), + ) + } + + const connect = async (isreconnect: boolean) => { + connectionElem.classList.toggle('loading', true) + dom._kids(connectionElem) + connectionElem.classList.toggle('loading', false) + + // We'll clear noreconnect when we've held a connection for 10 mins. + noreconnect = isreconnect + connecting = true + + let token: string + try { + token = await withStatus('Fetching token for connection with real-time updates', client.Token(), undefined, true) + } catch (err) { + connecting = false + noreconnect = true + dom._kids(statusElem, ((err as any).message || 'error fetching connection token')+', not automatically retrying') + showNotConnected() + return + } + + let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView) + requestMsgID = msgid + requestFilter = f + requestNotFilter = notf + if (searchQuery) { + loadSearch(searchQuery) + } + [f, notf] = refineFilters(requestFilter, requestNotFilter) + const fetchCount = Math.max(50, 3*Math.ceil(msglistscrollElem.getBoundingClientRect().height/msglistView.itemHeight())) + const query = { + OrderAsc: settings.orderAsc, + Filter: f, + NotFilter: notf, + } + const page = { + AnchorMessageID: 0, + Count: fetchCount, + DestMessageID: msgid, + } + + viewSequence++ + viewID = viewSequence + + // We get an implicit query for the automatically selected mailbox or query. + requestSequence++ + requestID = requestSequence + requestViewEnd = false + clearList() + + const request = { + ID: requestID, + // A new SSEID is created by the server, sent in the initial response message. + ViewID: viewID, + Query: query, + Page: page, + } + + let slow = '' + try { + const debug = JSON.parse(localStorage.getItem('sherpats-debug') || 'null') + if (debug && debug.waitMinMsec && debug.waitMaxMsec) { + slow = '&waitMinMsec='+debug.waitMinMsec + '&waitMaxMsec='+debug.waitMaxMsec + } + } catch (err) {} + + eventSource = new window.EventSource('events?token=' + encodeURIComponent(token)+'&request='+encodeURIComponent(JSON.stringify(request))+slow) + let eventID = window.setTimeout(() => dom._kids(statusElem, 'Connecting...'), 1000) + eventSource.addEventListener('open', (e: Event) => { + log('eventsource open', {e}) + if (eventID) { + window.clearTimeout(eventID) + eventID = 0 + } + dom._kids(statusElem) + dom._kids(connectionElem) + }) + + const sseError = (errmsg: string) => { + sseID = 0 + eventSource!.close() + eventSource = null + connecting = false + if (noreconnectTimer) { + clearTimeout(noreconnectTimer) + noreconnectTimer = 0 + } + if (leaving) { + return + } + if (eventID) { + window.clearTimeout(eventID) + eventID = 0 + } + document.title = ['(not connected)', loginAddress ? (loginAddress.User+'@'+(loginAddress.Domain.Unicode || loginAddress.Domain.ASCII)) : '', 'Mox Webmail'].filter(s => s).join(' - ') + dom._kids(connectionElem) + if (noreconnect) { + dom._kids(statusElem, errmsg+', not automatically retrying') + showNotConnected() + listloadingElem.remove() + listendElem.remove() + } else { + connect(true) + } + } + // EventSource-connection error. No details. + eventSource.addEventListener('error', (e: Event) => { + log('eventsource error', {e}, JSON.stringify(e)) + sseError('Connection failed') + }) + // Fatal error on the server side, error message propagated, but connection needs to be closed. + eventSource.addEventListener('fatalErr', (e: MessageEvent) => { + const errmsg = JSON.parse(e.data) as string || '(no error message)' + sseError('Server error: "' + errmsg + '"') + }) + + const checkParse = (fn: () => T): T => { + try { + return fn() + } catch (err) { + window.alert('invalid event from server: ' + ((err as any).message || '(no message)')) + throw err + } + } + + eventSource.addEventListener('start', (e: MessageEvent) => { + const start = checkParse(() => api.parser.EventStart(JSON.parse(e.data))) + log('event start', start) + + connecting = false + sseID = start.SSEID + loginAddress = start.LoginAddress + const loginAddr = formatEmailASCII(loginAddress) + accountAddresses = start.Addresses || [] + accountAddresses.sort((a, b) => { + if (formatEmailASCII(a) === loginAddr) { + return -1 + } + if (formatEmailASCII(b) === loginAddr) { + return 1 + } + if (a.Domain.ASCII != b.Domain.ASCII) { + return a.Domain.ASCII < b.Domain.ASCII ? -1 : 1 + } + return a.User < b.User ? -1 : 1 + }) + domainAddressConfigs = start.DomainAddressConfigs || {} + + clearList() + + let mailboxName = start.MailboxName + let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName) + if (mb) { + requestFilter.MailboxID = mb.ID // For check to display mailboxname in msgitemView. + } + if (mailboxName === '') { + mailboxName = (start.Mailboxes || []).find(mb => mb.ID === requestFilter.MailboxID)?.Name || '' + } + mailboxlistView.loadMailboxes(start.Mailboxes || [], search.active ? undefined : mailboxName) + if (searchView.root.parentElement) { + searchView.ensureLoaded() + } + + if (!mb) { + updatePageTitle() + } + dom._kids(queryactivityElem, 'loading...') + msglistscrollElem.appendChild(listloadingElem) + + noreconnectTimer = setTimeout(() => { + noreconnect = false + noreconnectTimer = 0 + }, 10*60*1000) + }) + eventSource.addEventListener('viewErr', async (e: MessageEvent) => { + const viewErr = checkParse(() => api.parser.EventViewErr(JSON.parse(e.data))) + log('event viewErr', viewErr) + if (viewErr.ViewID != viewID || viewErr.RequestID !== requestID) { + log('received viewErr for other viewID or requestID', {expected: {viewID, requestID}, got: {viewID: viewErr.ViewID, requestID: viewErr.RequestID}}) + return + } + + viewID = 0 + requestID = 0 + + dom._kids(queryactivityElem) + listloadingElem.remove() + listerrElem.remove() + dom._kids(listerrElem, 'Error from server during request for messages: '+viewErr.Err) + msglistscrollElem.appendChild(listerrElem) + window.alert('Error from server during request for messages: '+viewErr.Err) + }) + eventSource.addEventListener('viewReset', async (e: MessageEvent) => { + const viewReset = checkParse(() => api.parser.EventViewReset(JSON.parse(e.data))) + log('event viewReset', viewReset) + if (viewReset.ViewID != viewID || viewReset.RequestID !== requestID) { + log('received viewReset for other viewID or requestID', {expected: {viewID, requestID}, got: {viewID: viewReset.ViewID, requestID: viewReset.RequestID}}) + return + } + + clearList() + dom._kids(queryactivityElem, 'loading...') + msglistscrollElem.appendChild(listloadingElem) + window.alert('Could not find message to continue scrolling, resetting the view.') + }) + eventSource.addEventListener('viewMsgs', async (e: MessageEvent) => { + const viewMsgs = checkParse(() => api.parser.EventViewMsgs(JSON.parse(e.data))) + log('event viewMsgs', viewMsgs) + if (viewMsgs.ViewID != viewID || viewMsgs.RequestID !== requestID) { + log('received viewMsgs for other viewID or requestID', {expected: {viewID, requestID}, got: {viewID: viewMsgs.ViewID, requestID: viewMsgs.RequestID}}) + return + } + + msglistView.root.classList.toggle('loading', false) + const extramsgitemViews = (viewMsgs.MessageItems || []).map(mi => { + const othermb = requestFilter.MailboxID !== mi.Message.MailboxID ? mailboxlistView.findMailboxByID(mi.Message.MailboxID) : undefined + return newMsgitemView(mi, msglistView, othermb || null) + }) + + msglistView.addMsgitemViews(extramsgitemViews) + + if (viewMsgs.ParsedMessage) { + const msgID = viewMsgs.ParsedMessage.ID + const miv = extramsgitemViews.find(miv => miv.messageitem.Message.ID === msgID) + if (miv) { + msglistView.openMessage(miv, true, viewMsgs.ParsedMessage) + } else { + // Should not happen, server would be sending a parsedmessage while not including the message itself. + requestMsgID = 0 + setLocationHash() + } + } + + requestViewEnd = viewMsgs.ViewEnd + if (requestViewEnd) { + msglistscrollElem.appendChild(listendElem) + } + if ((viewMsgs.MessageItems || []).length === 0 || requestViewEnd) { + dom._kids(queryactivityElem) + listloadingElem.remove() + requestID = 0 + if (requestMsgID) { + requestMsgID = 0 + setLocationHash() + } + } + }) + eventSource.addEventListener('viewChanges', async (e: MessageEvent) => { + const viewChanges = checkParse(() => api.parser.EventViewChanges(JSON.parse(e.data))) + log('event viewChanges', viewChanges) + if (viewChanges.ViewID != viewID) { + log('received viewChanges for other viewID', {expected: viewID, got: viewChanges.ViewID}) + return + } + + try { + (viewChanges.Changes || []).forEach(tc => { + if (!tc) { + return + } + const [tag, x] = tc + if (tag === 'ChangeMailboxCounts') { + const c = api.parser.ChangeMailboxCounts(x) + mailboxlistView.setMailboxCounts(c.MailboxID, c.Total, c.Unread) + } else if (tag === 'ChangeMailboxSpecialUse') { + const c = api.parser.ChangeMailboxSpecialUse(x) + mailboxlistView.setMailboxSpecialUse(c.MailboxID, c.SpecialUse) + } else if (tag === 'ChangeMailboxKeywords') { + const c = api.parser.ChangeMailboxKeywords(x) + mailboxlistView.setMailboxKeywords(c.MailboxID, c.Keywords || []) + } else if (tag === 'ChangeMsgAdd') { + const c = api.parser.ChangeMsgAdd(x) + msglistView.addMessageItems([c.MessageItem]) + } else if (tag === 'ChangeMsgRemove') { + const c = api.parser.ChangeMsgRemove(x) + msglistView.removeUIDs(c.MailboxID, c.UIDs || []) + } else if (tag === 'ChangeMsgFlags') { + const c = api.parser.ChangeMsgFlags(x) + msglistView.updateFlags(c.MailboxID, c.UID, c.Mask, c.Flags, c.Keywords || []) + } else if (tag === 'ChangeMailboxRemove') { + const c = api.parser.ChangeMailboxRemove(x) + mailboxlistView.removeMailbox(c.MailboxID) + } else if (tag === 'ChangeMailboxAdd') { + const c = api.parser.ChangeMailboxAdd(x) + mailboxlistView.addMailbox(c.Mailbox) + } else if (tag === 'ChangeMailboxRename') { + const c = api.parser.ChangeMailboxRename(x) + mailboxlistView.renameMailbox(c.MailboxID, c.NewName) + } else { + throw new Error('unknown change tag ' + tag) + } + }) + } catch (err) { + window.alert('Error processing changes (reloading advised): ' + errmsg(err)) + } + }) + } + connect(false) +} + +window.addEventListener('load', async () => { + try { + await init() + } catch (err) { + window.alert('Error: ' + errmsg(err)) + } +}) + +// If a JS error happens, show a box in the lower left corner, with a button to +// show details, in a popup. The popup shows the error message and a link to github +// to create an issue. We want to lower the barrier to give feedback. +const showUnhandledError = (err: Error, lineno: number, colno: number) => { + console.log('unhandled error', err) + if (settings.ignoreErrorsUntil > new Date().getTime()/1000) { + return + } + let stack = err.stack || '' + if (stack) { + // Firefox has stacks with full location.href including hash at the time of + // writing, Chromium has location.href without hash. + const loc = window.location + stack = '\n'+stack.replaceAll(loc.href, 'webmail.html').replaceAll(loc.protocol+'//'+loc.host+loc.pathname+loc.search, 'webmail.html') + } else { + stack = ' (not available)' + } + const xerrmsg = err.toString() + const box = dom.div( + style({position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(249, 191, 191, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut}), + dom.div(style({marginBottom: '.5ex'}), ''+xerrmsg), + dom.clickbutton('Details', function click() { + box.remove() + let msg = `Mox version: ${moxversion} +Browser: ${window.navigator.userAgent} +File: webmail.html +Lineno: ${lineno || '-'} +Colno: ${colno || '-'} +Message: ${xerrmsg} + +Stack trace: ${stack} +` + + const body = `[Hi! Please replace this text with an explanation of what you did to trigger this errors. It will help us reproduce the problem. The more details, the more likely it is we can find and fix the problem. If you don't know how or why it happened, that's ok, it is still useful to report the problem. If no stack trace was found and included below, and you are a developer, you can probably find more details about the error in the browser developer console. Thanks!] + +Details of the error and browser: + +`+'```\n'+msg+'```\n' + + const remove = popup( + style({maxWidth: '60em'}), + dom.h1('A JavaScript error occurred'), + dom.pre(dom._class('mono'), + style({backgroundColor: '#f8f8f8', padding: '1ex', borderRadius: '.15em', border: '1px solid #ccc', whiteSpace: 'pre-wrap'}), + msg, + ), + dom.br(), + dom.div('There is a good chance this is a bug in Mox Webmail.'), + dom.div('Consider filing a bug report ("issue") at ', link('https://github.com/mjl-/mox/issues/new?title='+encodeURIComponent('mox webmail js error: "'+xerrmsg+'"')+'&body='+encodeURIComponent(body), 'https://github.com/mjl-/mox/issues/new'), '. The link includes the error details.'), + dom.div('Before reporting you could check previous ', link('https://github.com/mjl-/mox/issues?q=is%3Aissue+"mox+webmail+js+error%3A"', 'webmail bug reports'), '.'), + dom.br(), + dom.div('Your feedback will help improve mox, thanks!'), + dom.br(), + dom.div( + style({textAlign: 'right'}), + dom.clickbutton('Close and silence errors for 1 week', function click() { + remove() + settingsPut({...settings, ignoreErrorsUntil: Math.round(new Date().getTime()/1000 + 7*24*3600)}) + }), + ' ', + dom.clickbutton('Close', function click() { + remove() + }), + ), + ) + }), ' ', + dom.clickbutton('Ignore', function click() { + box.remove() + }), + ) + document.body.appendChild(box) +} + +// We don't catch all errors, we use throws to not continue executing javascript. +// But for JavaScript-level errors, we want to show a warning to helpfully get the +// user to submit a bug report. +window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + if (!e.reason) { + return + } + const err = e.reason + if (err instanceof EvalError || err instanceof RangeError || err instanceof ReferenceError || err instanceof SyntaxError || err instanceof TypeError || err instanceof URIError) { + showUnhandledError(err, 0, 0) + } else { + console.log('unhandled promiserejection', err, e.promise) + } +}) +// Window-level errors aren't that likely, since all code is in the init promise, +// but doesn't hurt to register an handler. +window.addEventListener('error', e => { + showUnhandledError(e.error, e.lineno, e.colno) +}) diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go new file mode 100644 index 0000000..58204ca --- /dev/null +++ b/webmail/webmail_test.go @@ -0,0 +1,540 @@ +package webmail + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "reflect" + "strings" + "testing" + "time" + + "golang.org/x/net/html" + + "github.com/mjl-/mox/message" + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/moxio" + "github.com/mjl-/mox/store" +) + +var ctxbg = context.Background() + +func tcheck(t *testing.T, err error, msg string) { + t.Helper() + if err != nil { + t.Fatalf("%s: %s", msg, err) + } +} + +func tcompare(t *testing.T, got, exp any) { + t.Helper() + if !reflect.DeepEqual(got, exp) { + t.Fatalf("got %v, expected %v", got, exp) + } +} + +type Message struct { + From, To, Cc, Bcc, Subject, MessageID string + Headers [][2]string + Date time.Time + Part Part +} + +type Part struct { + Type string + ID string + Disposition string + TransferEncoding string + + Content string + Parts []Part + + boundary string +} + +func (m Message) Marshal(t *testing.T) []byte { + if m.Date.IsZero() { + m.Date = time.Now() + } + if m.MessageID == "" { + m.MessageID = "<" + mox.MessageIDGen(false) + ">" + } + + var b bytes.Buffer + header := func(k, v string) { + if v == "" { + return + } + _, err := fmt.Fprintf(&b, "%s: %s\r\n", k, v) + tcheck(t, err, "write header") + } + + header("From", m.From) + header("To", m.To) + header("Cc", m.Cc) + header("Bcc", m.Bcc) + header("Subject", m.Subject) + header("Message-Id", m.MessageID) + header("Date", m.Date.Format(message.RFC5322Z)) + for _, t := range m.Headers { + header(t[0], t[1]) + } + header("Mime-Version", "1.0") + if len(m.Part.Parts) > 0 { + m.Part.boundary = multipart.NewWriter(io.Discard).Boundary() + } + m.Part.WriteHeader(t, &b) + m.Part.WriteBody(t, &b) + return b.Bytes() +} + +func (p Part) Header() textproto.MIMEHeader { + h := textproto.MIMEHeader{} + add := func(k, v string) { + if v != "" { + h.Add(k, v) + } + } + ct := p.Type + if p.boundary != "" { + ct += fmt.Sprintf(`; boundary="%s"`, p.boundary) + } + add("Content-Type", ct) + add("Content-Id", p.ID) + add("Content-Disposition", p.Disposition) + add("Content-Transfer-Encoding", p.TransferEncoding) // todo: ensure if not multipart? probably ensure before calling headre + return h +} + +func (p Part) WriteHeader(t *testing.T, w io.Writer) { + for k, vl := range p.Header() { + for _, v := range vl { + _, err := fmt.Fprintf(w, "%s: %s\r\n", k, v) + tcheck(t, err, "write header") + } + } + _, err := fmt.Fprint(w, "\r\n") + tcheck(t, err, "write line") +} + +func (p Part) WriteBody(t *testing.T, w io.Writer) { + if len(p.Parts) == 0 { + switch p.TransferEncoding { + case "base64": + bw := moxio.Base64Writer(w) + _, err := bw.Write([]byte(p.Content)) + tcheck(t, err, "writing base64") + err = bw.Close() + tcheck(t, err, "closing base64 part") + case "": + if p.Content == "" { + t.Fatalf("cannot write empty part") + } + if !strings.HasSuffix(p.Content, "\n") { + p.Content += "\n" + } + p.Content = strings.ReplaceAll(p.Content, "\n", "\r\n") + _, err := w.Write([]byte(p.Content)) + tcheck(t, err, "write content") + default: + t.Fatalf("unknown transfer-encoding %q", p.TransferEncoding) + } + return + } + + mp := multipart.NewWriter(w) + mp.SetBoundary(p.boundary) + for _, sp := range p.Parts { + if len(sp.Parts) > 0 { + sp.boundary = multipart.NewWriter(io.Discard).Boundary() + } + pw, err := mp.CreatePart(sp.Header()) + tcheck(t, err, "create part") + sp.WriteBody(t, pw) + } + err := mp.Close() + tcheck(t, err, "close multipart") +} + +var ( + msgMinimal = Message{ + Part: Part{Type: "text/plain", Content: "the body"}, + } + msgText = Message{ + From: "mjl ", + To: "mox ", + Subject: "text message", + Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"}, + } + msgHTML = Message{ + From: "mjl ", + To: "mox ", + Subject: "html message", + Part: Part{Type: "text/html", Content: `the body `}, + } + msgAlt = Message{ + From: "mjl ", + To: "mox ", + Subject: "test", + Headers: [][2]string{{"In-Reply-To", ""}}, + Part: Part{ + Type: "multipart/alternative", + Parts: []Part{ + {Type: "text/plain", Content: "the body"}, + {Type: "text/html; charset=utf-8", Content: `the body `}, + }, + }, + } + msgAltRel = Message{ + From: "mjl ", + To: "mox ", + Subject: "test with alt and rel", + Headers: [][2]string{{"X-Special", "testing"}}, + Part: Part{ + Type: "multipart/alternative", + Parts: []Part{ + {Type: "text/plain", Content: "the text body"}, + { + Type: "multipart/related", + Parts: []Part{ + { + Type: "text/html; charset=utf-8", + Content: `the body `, + }, + {Type: `image/png`, Disposition: `inline; filename="test1.png"`, ID: "", Content: `PNG...`, TransferEncoding: "base64"}, + }, + }, + }, + }, + } + msgAttachments = Message{ + From: "mjl ", + To: "mox ", + Subject: "test", + Part: Part{ + Type: "multipart/mixed", + Parts: []Part{ + {Type: "text/plain", Content: "the body"}, + {Type: "image/png", TransferEncoding: "base64", Content: `PNG...`}, + {Type: "image/png", TransferEncoding: "base64", Content: `PNG...`}, + {Type: `image/jpg; name="test.jpg"`, TransferEncoding: "base64", Content: `JPG...`}, + {Type: `image/jpg`, Disposition: `attachment; filename="test.jpg"`, TransferEncoding: "base64", Content: `JPG...`}, + }, + }, + } +) + +// Import test messages messages. +type testmsg struct { + Mailbox string + Flags store.Flags + Keywords []string + msg Message + m store.Message // As delivered. + ID int64 // Shortcut for m.ID +} + +func tdeliver(t *testing.T, acc *store.Account, tm *testmsg) { + msgFile, err := store.CreateMessageTemp("webmail-test") + tcheck(t, err, "create message temp") + size, err := msgFile.Write(tm.msg.Marshal(t)) + tcheck(t, err, "write message temp") + m := store.Message{Flags: tm.Flags, Keywords: tm.Keywords, Size: int64(size)} + err = acc.DeliverMailbox(xlog, tm.Mailbox, &m, msgFile, true) + tcheck(t, err, "deliver test message") + err = msgFile.Close() + tcheck(t, err, "closing test message") + tm.m = m + tm.ID = m.ID +} + +// Test scenario with an account with some mailboxes, messages, then make all +// kinds of changes and we check if we get the right events. +// todo: check more of the results, we currently mostly check http statuses, +// not the returned content. +func TestWebmail(t *testing.T) { + mox.LimitersInit() + os.RemoveAll("../testdata/webmail/data") + mox.Context = ctxbg + mox.ConfigStaticPath = "../testdata/webmail/mox.conf" + mox.MustLoadConfig(true, false) + switchDone := store.Switchboard() + defer close(switchDone) + + acc, err := store.OpenAccount("mjl") + tcheck(t, err, "open account") + err = acc.SetPassword("test1234") + tcheck(t, err, "set password") + defer func() { + err := acc.Close() + xlog.Check(err, "closing account") + }() + + api := Webmail{maxMessageSize: 1024 * 1024} + apiHandler, err := makeSherpaHandler(api.maxMessageSize) + tcheck(t, err, "sherpa handler") + + reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}} + ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo) + + tneedError(t, func() { api.MailboxCreate(ctx, "Inbox") }) // Cannot create inbox. + tneedError(t, func() { api.MailboxCreate(ctx, "Archive") }) // Already exists. + api.MailboxCreate(ctx, "Testbox1") + api.MailboxCreate(ctx, "Lists/Go/Nuts") // Creates hierarchy. + + var zerom store.Message + var ( + inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0} + inboxText = &testmsg{"Inbox", store.Flags{}, nil, msgText, zerom, 0} + inboxHTML = &testmsg{"Inbox", store.Flags{}, nil, msgHTML, zerom, 0} + inboxAlt = &testmsg{"Inbox", store.Flags{}, nil, msgAlt, zerom, 0} + inboxAltRel = &testmsg{"Inbox", store.Flags{}, nil, msgAltRel, zerom, 0} + inboxAttachments = &testmsg{"Inbox", store.Flags{}, nil, msgAttachments, zerom, 0} + testbox1Alt = &testmsg{"Testbox1", store.Flags{}, nil, msgAlt, zerom, 0} + rejectsMinimal = &testmsg{"Rejects", store.Flags{Junk: true}, nil, msgMinimal, zerom, 0} + ) + var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal} + + for _, tm := range testmsgs { + tdeliver(t, acc, tm) + } + + type httpHeaders [][2]string + ctHTML := [2]string{"Content-Type", "text/html; charset=utf-8"} + ctText := [2]string{"Content-Type", "text/plain; charset=utf-8"} + ctTextNoCharset := [2]string{"Content-Type", "text/plain"} + ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"} + ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"} + + const authOK = "mjl@mox.example:test1234" + const authBad = "mjl@mox.example:badpassword" + hdrAuthOK := [2]string{"Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(authOK))} + hdrAuthBad := [2]string{"Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(authBad))} + + testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) { + t.Helper() + + req := httptest.NewRequest(method, path, nil) + for _, kv := range headers { + req.Header.Add(kv[0], kv[1]) + } + rr := httptest.NewRecorder() + handle(apiHandler, rr, req) + if rr.Code != expStatusCode { + t.Fatalf("got status %d, expected %d", rr.Code, expStatusCode) + } + + resp := rr.Result() + for _, h := range expHeaders { + if resp.Header.Get(h[0]) != h[1] { + t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1]) + } + } + + if check != nil { + check(resp) + } + } + testHTTPAuth := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) { + t.Helper() + testHTTP(method, path, httpHeaders{hdrAuthOK}, expStatusCode, expHeaders, check) + } + + // HTTP webmail + testHTTP("GET", "/", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", "/", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", "/", http.StatusOK, httpHeaders{ctHTML}, nil) + testHTTPAuth("POST", "/", http.StatusMethodNotAllowed, nil, nil) + testHTTP("GET", "/", httpHeaders{hdrAuthOK, [2]string{"Accept-Encoding", "gzip"}}, http.StatusOK, httpHeaders{ctHTML, [2]string{"Content-Encoding", "gzip"}}, nil) + testHTTP("GET", "/msg.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("POST", "/msg.js", http.StatusMethodNotAllowed, nil, nil) + testHTTPAuth("GET", "/msg.js", http.StatusOK, httpHeaders{ctJS}, nil) + testHTTP("GET", "/text.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", "/text.js", http.StatusOK, httpHeaders{ctJS}, nil) + + testHTTP("GET", "/api/Bogus", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", "/api/Bogus", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", "/api/Bogus", http.StatusNotFound, nil, nil) + testHTTPAuth("GET", "/api/SSETypes", http.StatusOK, httpHeaders{ctJSON}, nil) + + // Unknown. + testHTTPAuth("GET", "/other", http.StatusNotFound, nil, nil) + + // HTTP message, generic + testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), nil, http.StatusUnauthorized, nil, nil) + testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", fmt.Sprintf("/msg/%v/attachments.zip", 0), http.StatusNotFound, nil, nil) + testHTTPAuth("GET", fmt.Sprintf("/msg/%v/attachments.zip", testmsgs[len(testmsgs)-1].ID+1), http.StatusNotFound, nil, nil) + testHTTPAuth("GET", fmt.Sprintf("/msg/%v/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil) + testHTTPAuth("GET", fmt.Sprintf("/msg/%v/view/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil) + testHTTPAuth("GET", fmt.Sprintf("/msg/%v/bogus/0", inboxMinimal.ID), http.StatusNotFound, nil, nil) + testHTTPAuth("GET", "/msg/", http.StatusNotFound, nil, nil) + testHTTPAuth("POST", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), http.StatusMethodNotAllowed, nil, nil) + + // HTTP message: attachments.zip + ctZip := [2]string{"Content-Type", "application/zip"} + checkZip := func(resp *http.Response, fileContents [][2]string) { + t.Helper() + zipbuf, err := io.ReadAll(resp.Body) + tcheck(t, err, "reading response") + zr, err := zip.NewReader(bytes.NewReader(zipbuf), int64(len(zipbuf))) + tcheck(t, err, "open zip") + if len(fileContents) != len(zr.File) { + t.Fatalf("zip file has %d files, expected %d", len(fileContents), len(zr.File)) + } + for i, fc := range fileContents { + if zr.File[i].Name != fc[0] { + t.Fatalf("zip, file at index %d is named %q, expected %q", i, zr.File[i].Name, fc[0]) + } + f, err := zr.File[i].Open() + tcheck(t, err, "open file in zip") + buf, err := io.ReadAll(f) + tcheck(t, err, "read file in zip") + tcompare(t, string(buf), fc[1]) + err = f.Close() + tcheck(t, err, "closing file") + } + } + + pathInboxMinimal := fmt.Sprintf("/msg/%d", inboxMinimal.ID) + testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + + testHTTPAuth("GET", pathInboxMinimal+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) { + checkZip(resp, nil) + }) + pathInboxRelAlt := fmt.Sprintf("/msg/%d", inboxAltRel.ID) + testHTTPAuth("GET", pathInboxRelAlt+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) { + checkZip(resp, [][2]string{{"test1.png", "PNG..."}}) + }) + pathInboxAttachments := fmt.Sprintf("/msg/%d", inboxAttachments.ID) + testHTTPAuth("GET", pathInboxAttachments+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) { + checkZip(resp, [][2]string{{"attachment-1.png", "PNG..."}, {"attachment-2.png", "PNG..."}, {"test.jpg", "JPG..."}, {"test-1.jpg", "JPG..."}}) + }) + + // HTTP message: raw + pathInboxAltRel := fmt.Sprintf("/msg/%d", inboxAltRel.ID) + pathInboxText := fmt.Sprintf("/msg/%d", inboxText.ID) + testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil) + testHTTPAuth("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil) + + // HTTP message: parsedmessage.js + testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", pathInboxMinimal+"/parsedmessage.js", http.StatusOK, httpHeaders{ctJS}, nil) + + mox.LimitersInit() + // HTTP message: text,html,htmlexternal and msgtext,msghtml,msghtmlexternal + for _, elem := range []string{"text", "html", "htmlexternal", "msgtext", "msghtml", "msghtmlexternal"} { + testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + mox.LimitersInit() // Reset, for too many failures. + } + + // The text endpoint serves JS that we generated, so should be safe, but still doesn't hurt to have a CSP. + cspText := [2]string{ + "Content-Security-Policy", + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", + } + // HTML as viewed in the regular viewer, not in a new tab. + cspHTML := [2]string{ + "Content-Security-Policy", + "sandbox allow-popups allow-popups-to-escape-sandbox; frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'", + } + // HTML when in separate message tab, needs allow-same-origin for iframe inner height. + cspHTMLSameOrigin := [2]string{ + "Content-Security-Policy", + "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'", + } + // Like cspHTML, but allows http and https resources. + cspHTMLExternal := [2]string{ + "Content-Security-Policy", + "sandbox allow-popups allow-popups-to-escape-sandbox; frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:", + } + // HTML with external resources when opened in separate tab, with allow-same-origin for iframe inner height. + cspHTMLExternalSameOrigin := [2]string{ + "Content-Security-Policy", + "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:", + } + // Msg page, our JS, that loads an html iframe, already blocks access for the iframe. + cspMsgHTML := [2]string{ + "Content-Security-Policy", + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", + } + // Msg page that already allows external resources for the iframe. + cspMsgHTMLExternal := [2]string{ + "Content-Security-Policy", + "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", + } + testHTTPAuth("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/msghtml", http.StatusOK, httpHeaders{ctHTML, cspMsgHTML}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/msghtmlexternal", http.StatusOK, httpHeaders{ctHTML, cspMsgHTMLExternal}, nil) + + testHTTPAuth("GET", pathInboxAltRel+"/html?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLSameOrigin}, nil) + testHTTPAuth("GET", pathInboxAltRel+"/htmlexternal?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternalSameOrigin}, nil) + + // No HTML part. + for _, elem := range []string{"html", "htmlexternal", "msghtml", "msghtmlexternal"} { + testHTTPAuth("GET", pathInboxText+"/"+elem, http.StatusBadRequest, nil, nil) + + } + // No text part. + pathInboxHTML := fmt.Sprintf("/msg/%d", inboxHTML.ID) + for _, elem := range []string{"text", "msgtext"} { + testHTTPAuth("GET", pathInboxHTML+"/"+elem, http.StatusBadRequest, nil, nil) + } + + // HTTP message part: view,viewtext,download + for _, elem := range []string{"view", "viewtext", "download"} { + testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{}, http.StatusUnauthorized, nil, nil) + testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0", http.StatusOK, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.0", http.StatusOK, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.1", http.StatusOK, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.2", http.StatusNotFound, nil, nil) + testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/1", http.StatusNotFound, nil, nil) + } +} + +func TestSanitize(t *testing.T) { + check := func(s string, exp string) { + t.Helper() + n, err := html.Parse(strings.NewReader(s)) + tcheck(t, err, "parsing html") + sanitizeNode(n) + var sb strings.Builder + err = html.Render(&sb, n) + tcheck(t, err, "writing html") + if sb.String() != exp { + t.Fatalf("sanitizing html: %s\ngot: %s\nexpected: %s", s, sb.String(), exp) + } + } + + check(``, + ``) + check(``, + ``) + check(`click me`, + `click me`) + check(`click me`, + `click me`) + check(`click me`, + `click me`) + check(`click me`, + `click me`) + check(``, + ``) +}