mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +03:00
add webmail
it was far down on the roadmap, but implemented earlier, because it's interesting, and to help prepare for a jmap implementation. for jmap we need to implement more client-like functionality than with just imap. internal data structures need to change. jmap has lots of other requirements, so it's already a big project. by implementing a webmail now, some of the required data structure changes become clear and can be made now, so the later jmap implementation can do things similarly to the webmail code. the webmail frontend and webmail are written together, making their interface/api much smaller and simpler than jmap. one of the internal changes is that we now keep track of per-mailbox total/unread/unseen/deleted message counts and mailbox sizes. keeping this data consistent after any change to the stored messages (through the code base) is tricky, so mox now has a consistency check that verifies the counts are correct, which runs only during tests, each time an internal account reference is closed. we have a few more internal "changes" that are propagated for the webmail frontend (that imap doesn't have a way to propagate on a connection), like changes to the special-use flags on mailboxes, and used keywords in a mailbox. more changes that will be required have revealed themselves while implementing the webmail, and will be implemented next. the webmail user interface is modeled after the mail clients i use or have used: thunderbird, macos mail, mutt; and webmails i normally only use for testing: gmail, proton, yahoo, outlook. a somewhat technical user is assumed, but still the goal is to make this webmail client easy to use for everyone. the user interface looks like most other mail clients: a list of mailboxes, a search bar, a message list view, and message details. there is a top/bottom and a left/right layout for the list/message view, default is automatic based on screen size. the panes can be resized by the user. buttons for actions are just text, not icons. clicking a button briefly shows the shortcut for the action in the bottom right, helping with learning to operate quickly. any text that is underdotted has a title attribute that causes more information to be displayed, e.g. what a button does or a field is about. to highlight potential phishing attempts, any text (anywhere in the webclient) that switches unicode "blocks" (a rough approximation to (language) scripts) within a word is underlined orange. multiple messages can be selected with familiar ui interaction: clicking while holding control and/or shift keys. keyboard navigation works with arrows/page up/down and home/end keys, and also with a few basic vi-like keys for list/message navigation. we prefer showing the text instead of html (with inlined images only) version of a message. html messages are shown in an iframe served from an endpoint with CSP headers to prevent dangerous resources (scripts, external images) from being loaded. the html is also sanitized, with javascript removed. a user can choose to load external resources (e.g. images for tracking purposes). the frontend is just (strict) typescript, no external frameworks. all incoming/outgoing data is typechecked, both the api request parameters and response types, and the data coming in over SSE. the types and checking code are generated with sherpats, which uses the api definitions generated by sherpadoc based on the Go code. so types from the backend are automatically propagated to the frontend. since there is no framework to automatically propagate properties and rerender components, changes coming in over the SSE connection are propagated explicitly with regular function calls. the ui is separated into "views", each with a "root" dom element that is added to the visible document. these views have additional functions for getting changes propagated, often resulting in the view updating its (internal) ui state (dom). we keep the frontend compilation simple, it's just a few typescript files that get compiled (combined and types stripped) into a single js file, no additional runtime code needed or complicated build processes used. the webmail is served is served from a compressed, cachable html file that includes style and the javascript, currently just over 225kb uncompressed, under 60kb compressed (not minified, including comments). we include the generated js files in the repository, to keep Go's easily buildable self-contained binaries. authentication is basic http, as with the account and admin pages. most data comes in over one long-term SSE connection to the backend. api requests signal which mailbox/search/messages are requested over the SSE connection. fetching individual messages, and making changes, are done through api calls. the operations are similar to imap, so some code has been moved from package imapserver to package store. the future jmap implementation will benefit from these changes too. more functionality will probably be moved to the store package in the future. the quickstart enables webmail on the internal listener by default (for new installs). users can enable it on the public listener if they want to. mox localserve enables it too. to enable webmail on existing installs, add settings like the following to the listeners in mox.conf, similar to AccountHTTP(S): WebmailHTTP: Enabled: true WebmailHTTPS: Enabled: true special thanks to liesbeth, gerben, andrii for early user feedback. there is plenty still to do, see the list at the top of webmail/webmail.ts. feedback welcome as always.
This commit is contained in:
1493
webmail/api.go
Normal file
1493
webmail/api.go
Normal file
File diff suppressed because it is too large
Load Diff
2415
webmail/api.json
Normal file
2415
webmail/api.json
Normal file
File diff suppressed because it is too large
Load Diff
1114
webmail/api.ts
Normal file
1114
webmail/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
365
webmail/api_test.go
Normal file
365
webmail/api_test.go
Normal file
@ -0,0 +1,365 @@
|
||||
package webmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
"github.com/mjl-/sherpa"
|
||||
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func tneedError(t *testing.T, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
t.Helper()
|
||||
x := recover()
|
||||
if x == nil {
|
||||
debug.PrintStack()
|
||||
t.Fatalf("expected sherpa user error, saw success")
|
||||
}
|
||||
if err, ok := x.(*sherpa.Error); !ok {
|
||||
debug.PrintStack()
|
||||
t.Fatalf("expected sherpa user error, saw %#v", x)
|
||||
} else if err.Code != "user:error" {
|
||||
debug.PrintStack()
|
||||
t.Fatalf("expected sherpa user error, saw other sherpa error %#v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
// Test API calls.
|
||||
// todo: test that the actions make the changes they claim to make. we currently just call the functions and have only limited checks that state changed.
|
||||
func TestAPI(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")
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
api := Webmail{maxMessageSize: 1024 * 1024}
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
|
||||
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
|
||||
// FlagsAdd
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`})
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`}) // No change.
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{}) // Nothing to do.
|
||||
api.FlagsAdd(ctx, []int64{}, []string{}) // No messages, no flags.
|
||||
api.FlagsAdd(ctx, []int64{}, []string{`custom`}) // No message, new flag.
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$junk`}) // Trigger retrain.
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$notjunk`}) // Trigger retrain.
|
||||
api.FlagsAdd(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`}) // Trigger retrain, messages in different mailboxes.
|
||||
api.FlagsAdd(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`, `newlabel`}) // Two mailboxes with counts and keywords changed.
|
||||
tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
|
||||
tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{``}) }) // Empty is invalid.
|
||||
tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) }) // Only predefined system flags.
|
||||
|
||||
// FlagsClear, inverse of FlagsAdd.
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{})
|
||||
api.FlagsClear(ctx, []int64{}, []string{})
|
||||
api.FlagsClear(ctx, []int64{}, []string{`custom`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$junk`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$notjunk`})
|
||||
api.FlagsClear(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`})
|
||||
api.FlagsClear(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`}) // Two mailboxes with counts changed.
|
||||
tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
|
||||
tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{``}) })
|
||||
tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
|
||||
|
||||
// MailboxSetSpecialUse
|
||||
var inbox, archive, sent, testbox1 store.Mailbox
|
||||
err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
|
||||
get := func(k string, v any) store.Mailbox {
|
||||
mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get()
|
||||
tcheck(t, err, "get special-use mailbox")
|
||||
return mb
|
||||
}
|
||||
get("Draft", true)
|
||||
sent = get("Sent", true)
|
||||
archive = get("Archive", true)
|
||||
get("Trash", true)
|
||||
get("Junk", true)
|
||||
|
||||
inbox = get("Name", "Inbox")
|
||||
testbox1 = get("Name", "Testbox1")
|
||||
return nil
|
||||
})
|
||||
tcheck(t, err, "get mailboxes")
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: archive.ID, SpecialUse: store.SpecialUse{Draft: true}}) // Already set.
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true}}) // New draft mailbox.
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Sent: true}})
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Archive: true}})
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Trash: true}})
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Junk: true}})
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true, Sent: true, Archive: true, Trash: true, Junk: true}}) // All
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None again.
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{Sent: true}}) // Sent, for sending mail later.
|
||||
tneedError(t, func() { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: 0}) })
|
||||
|
||||
// MailboxRename
|
||||
api.MailboxRename(ctx, testbox1.ID, "Testbox2")
|
||||
api.MailboxRename(ctx, testbox1.ID, "Test/A/B/Box1")
|
||||
api.MailboxRename(ctx, testbox1.ID, "Test/A/Box1")
|
||||
api.MailboxRename(ctx, testbox1.ID, "Testbox1")
|
||||
tneedError(t, func() { api.MailboxRename(ctx, 0, "BadID") })
|
||||
tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Testbox1") }) // Already this name.
|
||||
tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Inbox") }) // Inbox not allowed.
|
||||
tneedError(t, func() { api.MailboxRename(ctx, inbox.ID, "Binbox") }) // Inbox not allowed.
|
||||
tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Archive") }) // Exists.
|
||||
|
||||
// ParsedMessage
|
||||
// todo: verify contents
|
||||
api.ParsedMessage(ctx, inboxMinimal.ID)
|
||||
api.ParsedMessage(ctx, inboxText.ID)
|
||||
api.ParsedMessage(ctx, inboxHTML.ID)
|
||||
api.ParsedMessage(ctx, inboxAlt.ID)
|
||||
api.ParsedMessage(ctx, inboxAltRel.ID)
|
||||
api.ParsedMessage(ctx, testbox1Alt.ID)
|
||||
tneedError(t, func() { api.ParsedMessage(ctx, 0) })
|
||||
tneedError(t, func() { api.ParsedMessage(ctx, testmsgs[len(testmsgs)-1].ID+1) })
|
||||
|
||||
// MailboxDelete
|
||||
api.MailboxDelete(ctx, testbox1.ID)
|
||||
testa, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Test/A").Get()
|
||||
tcheck(t, err, "get mailbox Test/A")
|
||||
tneedError(t, func() { api.MailboxDelete(ctx, testa.ID) }) // Test/A/B still exists.
|
||||
tneedError(t, func() { api.MailboxDelete(ctx, 0) }) // Bad ID.
|
||||
tneedError(t, func() { api.MailboxDelete(ctx, testbox1.ID) }) // No longer exists.
|
||||
tneedError(t, func() { api.MailboxDelete(ctx, inbox.ID) }) // Cannot remove inbox.
|
||||
tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
|
||||
|
||||
api.MailboxCreate(ctx, "Testbox1")
|
||||
testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Testbox1").Get()
|
||||
tcheck(t, err, "get testbox1")
|
||||
tdeliver(t, acc, testbox1Alt)
|
||||
|
||||
// MailboxEmpty
|
||||
api.MailboxEmpty(ctx, testbox1.ID)
|
||||
tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
|
||||
tneedError(t, func() { api.MailboxEmpty(ctx, 0) }) // Bad ID.
|
||||
|
||||
// MessageMove
|
||||
tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, inbox.ID) }) // Message was removed (with MailboxEmpty above).
|
||||
api.MessageMove(ctx, []int64{}, testbox1.ID) // No messages.
|
||||
tdeliver(t, acc, testbox1Alt)
|
||||
tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID) }) // Already in destination mailbox.
|
||||
tneedError(t, func() { api.MessageMove(ctx, []int64{}, 0) }) // Bad ID.
|
||||
api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}, testbox1.ID)
|
||||
api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID, testbox1Alt.ID}, inbox.ID) // From different mailboxes.
|
||||
api.FlagsAdd(ctx, []int64{inboxMinimal.ID}, []string{`minimallabel`}) // For move.
|
||||
api.MessageMove(ctx, []int64{inboxMinimal.ID}, testbox1.ID) // Move causes new label for destination mailbox.
|
||||
api.MessageMove(ctx, []int64{rejectsMinimal.ID}, testbox1.ID) // Move causing readjustment of MailboxOrigID due to Rejects mailbox.
|
||||
tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID, inboxMinimal.ID}, testbox1.ID) }) // inboxMinimal already in destination.
|
||||
// Restore.
|
||||
api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID)
|
||||
api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID)
|
||||
|
||||
// MessageDelete
|
||||
api.MessageDelete(ctx, []int64{}) // No messages.
|
||||
api.MessageDelete(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}) // Same mailbox.
|
||||
api.MessageDelete(ctx, []int64{inboxText.ID, testbox1Alt.ID, inboxAltRel.ID}) // Multiple mailboxes, multiple times.
|
||||
tneedError(t, func() { api.MessageDelete(ctx, []int64{0}) }) // Bad ID.
|
||||
tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID + 999}) }) // Bad ID
|
||||
tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID}) }) // Already removed.
|
||||
tdeliver(t, acc, testbox1Alt)
|
||||
tdeliver(t, acc, inboxAltRel)
|
||||
|
||||
// MessageSubmit
|
||||
queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
|
||||
Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
|
||||
Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
|
||||
Subject: "test email",
|
||||
TextBody: "this is the content\n\ncheers,\nmox",
|
||||
ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
|
||||
UserAgent: "moxwebmail/dev",
|
||||
})
|
||||
// todo: check delivery of 6 messages to inbox, 1 to sent
|
||||
|
||||
// Reply with attachments.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
Subject: "Re: reply with attachments",
|
||||
TextBody: "sending you these fake png files",
|
||||
Attachments: []File{
|
||||
{
|
||||
Filename: "test1.png",
|
||||
DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
|
||||
},
|
||||
{
|
||||
Filename: "test1.png",
|
||||
DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
|
||||
},
|
||||
},
|
||||
ResponseMessageID: testbox1Alt.ID,
|
||||
})
|
||||
// todo: check answered flag
|
||||
|
||||
// Forward with attachments.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
Subject: "Fwd: the original subject",
|
||||
TextBody: "look what i got",
|
||||
Attachments: []File{
|
||||
{
|
||||
Filename: "test1.png",
|
||||
DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
|
||||
},
|
||||
},
|
||||
ForwardAttachments: ForwardAttachments{
|
||||
MessageID: inboxAltRel.ID,
|
||||
Paths: [][]int{{1, 1}, {1, 1}},
|
||||
},
|
||||
IsForward: true,
|
||||
ResponseMessageID: testbox1Alt.ID,
|
||||
})
|
||||
// todo: check forwarded flag, check it has the right attachments.
|
||||
|
||||
// Send from utf8 localpart.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "møx@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
TextBody: "test",
|
||||
})
|
||||
|
||||
// Send to utf8 localpart.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"møx@mox.example"},
|
||||
TextBody: "test",
|
||||
})
|
||||
|
||||
// Send to utf-8 text.
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
Subject: "hi ☺",
|
||||
TextBody: fmt.Sprintf("%80s", "tést"),
|
||||
})
|
||||
|
||||
// Send without special-use Sent mailbox.
|
||||
api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
Subject: "hi ☺",
|
||||
TextBody: fmt.Sprintf("%80s", "tést"),
|
||||
})
|
||||
|
||||
// Message with From-address of another account.
|
||||
tneedError(t, func() {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "other@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
TextBody: "test",
|
||||
})
|
||||
})
|
||||
|
||||
// Message with unknown address.
|
||||
tneedError(t, func() {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "doesnotexist@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
TextBody: "test",
|
||||
})
|
||||
})
|
||||
|
||||
// Message without recipient.
|
||||
tneedError(t, func() {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
TextBody: "test",
|
||||
})
|
||||
})
|
||||
|
||||
api.maxMessageSize = 1
|
||||
tneedError(t, func() {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: "mjl@mox.example",
|
||||
To: []string{"mjl+to@mox.example"},
|
||||
Subject: "too large",
|
||||
TextBody: "so many bytes",
|
||||
})
|
||||
})
|
||||
api.maxMessageSize = 1024 * 1024
|
||||
|
||||
// Hit recipient limit.
|
||||
tneedError(t, func() {
|
||||
accConf, _ := acc.Conf()
|
||||
for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: fmt.Sprintf("user@mox%d.example", i),
|
||||
TextBody: "test",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Hit message limit.
|
||||
tneedError(t, func() {
|
||||
accConf, _ := acc.Conf()
|
||||
for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
|
||||
api.MessageSubmit(ctx, SubmitMessage{
|
||||
From: fmt.Sprintf("user@mox%d.example", i),
|
||||
TextBody: "test",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
l, full := api.CompleteRecipient(ctx, "doesnotexist")
|
||||
tcompare(t, len(l), 0)
|
||||
tcompare(t, full, true)
|
||||
l, full = api.CompleteRecipient(ctx, "cc2")
|
||||
tcompare(t, l, []string{"mjl cc2 <mjl+cc2@mox.example>"})
|
||||
tcompare(t, full, true)
|
||||
}
|
170
webmail/eventwriter.go
Normal file
170
webmail/eventwriter.go
Normal file
@ -0,0 +1,170 @@
|
||||
package webmail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/metrics"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
)
|
||||
|
||||
type eventWriter struct {
|
||||
out writeFlusher
|
||||
waitMin, waitMax time.Duration
|
||||
|
||||
// If connection is closed, the goroutine doing delayed writes must abort.
|
||||
sync.Mutex
|
||||
closed bool
|
||||
|
||||
wrote bool // To be reset by user, set on write.
|
||||
events chan struct {
|
||||
name string // E.g. "start" for EventStart.
|
||||
v any // Written as JSON.
|
||||
when time.Time // For delaying.
|
||||
} // Will only be set when waitMin or waitMax is > 0. Closed on connection shutdown.
|
||||
errors chan error // If we have an events channel, we read errors and abort for them.
|
||||
}
|
||||
|
||||
func newEventWriter(out writeFlusher, waitMin, waitMax time.Duration) *eventWriter {
|
||||
return &eventWriter{out: out, waitMin: waitMin, waitMax: waitMax}
|
||||
}
|
||||
|
||||
// close shuts down the events channel, causing the goroutine (if created) to
|
||||
// stop.
|
||||
func (ew *eventWriter) close() {
|
||||
if ew.events != nil {
|
||||
close(ew.events)
|
||||
}
|
||||
ew.Lock()
|
||||
defer ew.Unlock()
|
||||
ew.closed = true
|
||||
}
|
||||
|
||||
// Write an event to the connection, e.g. "start" with value v, written as
|
||||
// JSON. This directly writes the event, no more delay.
|
||||
func (ew *eventWriter) write(name string, v any) error {
|
||||
bw := bufio.NewWriter(ew.out)
|
||||
if _, err := fmt.Fprintf(bw, "event: %s\ndata: ", name); err != nil {
|
||||
return err
|
||||
} else if err := json.NewEncoder(bw).Encode(v); err != nil {
|
||||
return err
|
||||
} else if _, err := fmt.Fprint(bw, "\n"); err != nil {
|
||||
return err
|
||||
} else if err := bw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
return ew.out.Flush()
|
||||
}
|
||||
|
||||
// For random wait between min and max delay.
|
||||
var waitGen = mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// Schedule an event for writing to the connection. If events get a delay, this
|
||||
// function still returns immediately.
|
||||
func (ew *eventWriter) xsendEvent(ctx context.Context, log *mlog.Log, name string, v any) {
|
||||
if (ew.waitMin > 0 || ew.waitMax > 0) && ew.events == nil {
|
||||
// First write on a connection with delay.
|
||||
ew.events = make(chan struct {
|
||||
name string
|
||||
v any
|
||||
when time.Time
|
||||
}, 100)
|
||||
ew.errors = make(chan error)
|
||||
go func() {
|
||||
defer func() {
|
||||
x := recover() // Should not happen, but don't take program down if it does.
|
||||
if x != nil {
|
||||
log.WithContext(ctx).Error("writeEvent panic", mlog.Field("err", x))
|
||||
debug.PrintStack()
|
||||
metrics.PanicInc("webmail-sendEvent")
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
ev, ok := <-ew.events
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d := time.Until(ev.when)
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
ew.Lock()
|
||||
if ew.closed {
|
||||
ew.Unlock()
|
||||
return
|
||||
}
|
||||
err := ew.write(ev.name, ev.v)
|
||||
ew.Unlock()
|
||||
if err != nil {
|
||||
ew.errors <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Check for previous write error before continuing.
|
||||
if ew.errors != nil {
|
||||
select {
|
||||
case err := <-ew.errors:
|
||||
panic(ioErr{err})
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// If we have an events channel, we have a goroutine that write the events, delayed.
|
||||
if ew.events != nil {
|
||||
wait := ew.waitMin + time.Duration(waitGen.Intn(1000))*(ew.waitMax-ew.waitMin)/1000
|
||||
when := time.Now().Add(wait)
|
||||
ew.events <- struct {
|
||||
name string
|
||||
v any
|
||||
when time.Time
|
||||
}{name, v, when}
|
||||
} else {
|
||||
err := ew.write(name, v)
|
||||
if err != nil {
|
||||
panic(ioErr{err})
|
||||
}
|
||||
}
|
||||
ew.wrote = true
|
||||
}
|
||||
|
||||
// writeFlusher is a writer and flusher. We need to flush after writing an
|
||||
// Event. Both to flush pending gzip data to the http response, and the http
|
||||
// response to the client.
|
||||
type writeFlusher interface {
|
||||
io.Writer
|
||||
Flush() error
|
||||
}
|
||||
|
||||
// nopFlusher is a standin for writeFlusher if gzip is not used.
|
||||
type nopFlusher struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (f nopFlusher) Flush() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpFlusher wraps Flush for a writeFlusher with a call to an http.Flusher.
|
||||
type httpFlusher struct {
|
||||
writeFlusher
|
||||
f http.Flusher
|
||||
}
|
||||
|
||||
// Flush flushes the underlying writeFlusher, and calls Flush on the http.Flusher
|
||||
// (which doesn't return an error).
|
||||
func (f httpFlusher) Flush() error {
|
||||
err := f.writeFlusher.Flush()
|
||||
f.f.Flush()
|
||||
return err
|
||||
}
|
383
webmail/lib.ts
Normal file
383
webmail/lib.ts
Normal file
@ -0,0 +1,383 @@
|
||||
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
|
||||
|
||||
type ElemArg = string | Element | Function | {_class: string[]} | {_attrs: {[k: string]: string}} | {_styles: {[k: string]: string | number}} | {_props: {[k: string]: any}} | {root: HTMLElement} | ElemArg[]
|
||||
|
||||
const [dom, style, attr, prop] = (function() {
|
||||
|
||||
// Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt
|
||||
const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]
|
||||
|
||||
// Find block code belongs in.
|
||||
const findBlock = (code: number): number => {
|
||||
let s = 0
|
||||
let e = scriptblocks.length
|
||||
while (s < e-1) {
|
||||
let i = Math.floor((s+e)/2)
|
||||
if (code < scriptblocks[i]) {
|
||||
e = i
|
||||
} else {
|
||||
s = i
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatText adds s to element e, in a way that makes switching unicode scripts
|
||||
// clear, with alternating DOM TextNode and span elements with a "switchscript"
|
||||
// class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic
|
||||
// 0x430).
|
||||
//
|
||||
// This is only called one string at a time, so the UI can still display strings
|
||||
// without highlighting switching scripts, by calling formatText on the parts.
|
||||
const formatText = (e: HTMLElement, s: string): void => {
|
||||
// Handle some common cases quickly.
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
let ascii = true
|
||||
for (const c of s) {
|
||||
const cp = c.codePointAt(0) // For typescript, to check for undefined.
|
||||
if (cp !== undefined && cp >= 0x0080) {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ascii) {
|
||||
e.appendChild(document.createTextNode(s))
|
||||
return
|
||||
}
|
||||
|
||||
// todo: handle grapheme clusters? wait for Intl.Segmenter?
|
||||
|
||||
let n = 0 // Number of text/span parts added.
|
||||
let str = '' // Collected so far.
|
||||
let block = -1 // Previous block/script.
|
||||
let mod = 1
|
||||
const put = (nextblock: number) => {
|
||||
if (n === 0 && nextblock === 0) {
|
||||
// Start was non-ascii, second block is ascii, we'll start marked as switched.
|
||||
mod = 0
|
||||
}
|
||||
if (n % 2 === mod) {
|
||||
const x = document.createElement('span')
|
||||
x.classList.add('scriptswitch')
|
||||
x.appendChild(document.createTextNode(str))
|
||||
e.appendChild(x)
|
||||
} else {
|
||||
e.appendChild(document.createTextNode(str))
|
||||
}
|
||||
n++
|
||||
str = ''
|
||||
}
|
||||
for (const c of s) {
|
||||
// Basic whitespace does not switch blocks. Will probably need to extend with more
|
||||
// punctuation in the future. Possibly for digits too. But perhaps not in all
|
||||
// scripts.
|
||||
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
|
||||
str += c
|
||||
continue
|
||||
}
|
||||
const code: number = c.codePointAt(0) as number
|
||||
if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block+1] || block === scriptblocks.length-1))) {
|
||||
const nextblock = code < 0x0080 ? 0 : findBlock(code)
|
||||
if (block >= 0) {
|
||||
put(nextblock)
|
||||
}
|
||||
block = nextblock
|
||||
}
|
||||
str += c
|
||||
}
|
||||
put(-1)
|
||||
}
|
||||
|
||||
const _domKids = <T extends HTMLElement>(e: T, l: ElemArg[]): T => {
|
||||
l.forEach((c) => {
|
||||
const xc = c as {[k: string]: any}
|
||||
if (typeof c === 'string') {
|
||||
formatText(e, c)
|
||||
} else if (c instanceof Element) {
|
||||
e.appendChild(c)
|
||||
} else if (c instanceof Function) {
|
||||
if (!c.name) {
|
||||
throw new Error('function without name')
|
||||
}
|
||||
e.addEventListener(c.name as string, c as EventListener)
|
||||
} else if (Array.isArray(xc)) {
|
||||
_domKids(e, c as ElemArg[])
|
||||
} else if (xc._class) {
|
||||
for (const s of xc._class) {
|
||||
e.classList.toggle(s, true)
|
||||
}
|
||||
} else if (xc._attrs) {
|
||||
for (const k in xc._attrs) {
|
||||
e.setAttribute(k, xc._attrs[k])
|
||||
}
|
||||
} else if (xc._styles) {
|
||||
for (const k in xc._styles) {
|
||||
const estyle: {[k: string]: any} = e.style
|
||||
estyle[k as string] = xc._styles[k]
|
||||
}
|
||||
} else if (xc._props) {
|
||||
for (const k in xc._props) {
|
||||
const eprops: {[k: string]: any} = e
|
||||
eprops[k] = xc._props[k]
|
||||
}
|
||||
} else if (xc.root) {
|
||||
e.appendChild(xc.root)
|
||||
} else {
|
||||
console.log('bad kid', c)
|
||||
throw new Error('bad kid')
|
||||
}
|
||||
})
|
||||
return e
|
||||
}
|
||||
const dom = {
|
||||
_kids: function(e: HTMLElement, ...kl: ElemArg[]) {
|
||||
while(e.firstChild) {
|
||||
e.removeChild(e.firstChild)
|
||||
}
|
||||
_domKids(e, kl)
|
||||
},
|
||||
_attrs: (x: {[k: string]: string}) => { return {_attrs: x}},
|
||||
_class: (...x: string[]) => { return {_class: x}},
|
||||
// The createElement calls are spelled out so typescript can derive function
|
||||
// signatures with a specific HTML*Element return type.
|
||||
div: (...l: ElemArg[]) => _domKids(document.createElement('div'), l),
|
||||
span: (...l: ElemArg[]) => _domKids(document.createElement('span'), l),
|
||||
a: (...l: ElemArg[]) => _domKids(document.createElement('a'), l),
|
||||
input: (...l: ElemArg[]) => _domKids(document.createElement('input'), l),
|
||||
textarea: (...l: ElemArg[]) => _domKids(document.createElement('textarea'), l),
|
||||
select: (...l: ElemArg[]) => _domKids(document.createElement('select'), l),
|
||||
option: (...l: ElemArg[]) => _domKids(document.createElement('option'), l),
|
||||
clickbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('button'), ...l]),
|
||||
submitbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]),
|
||||
form: (...l: ElemArg[]) => _domKids(document.createElement('form'), l),
|
||||
fieldset: (...l: ElemArg[]) => _domKids(document.createElement('fieldset'), l),
|
||||
table: (...l: ElemArg[]) => _domKids(document.createElement('table'), l),
|
||||
thead: (...l: ElemArg[]) => _domKids(document.createElement('thead'), l),
|
||||
tbody: (...l: ElemArg[]) => _domKids(document.createElement('tbody'), l),
|
||||
tr: (...l: ElemArg[]) => _domKids(document.createElement('tr'), l),
|
||||
td: (...l: ElemArg[]) => _domKids(document.createElement('td'), l),
|
||||
th: (...l: ElemArg[]) => _domKids(document.createElement('th'), l),
|
||||
datalist: (...l: ElemArg[]) => _domKids(document.createElement('datalist'), l),
|
||||
h1: (...l: ElemArg[]) => _domKids(document.createElement('h1'), l),
|
||||
h2: (...l: ElemArg[]) => _domKids(document.createElement('h2'), l),
|
||||
br: (...l: ElemArg[]) => _domKids(document.createElement('br'), l),
|
||||
hr: (...l: ElemArg[]) => _domKids(document.createElement('hr'), l),
|
||||
pre: (...l: ElemArg[]) => _domKids(document.createElement('pre'), l),
|
||||
label: (...l: ElemArg[]) => _domKids(document.createElement('label'), l),
|
||||
ul: (...l: ElemArg[]) => _domKids(document.createElement('ul'), l),
|
||||
li: (...l: ElemArg[]) => _domKids(document.createElement('li'), l),
|
||||
iframe: (...l: ElemArg[]) => _domKids(document.createElement('iframe'), l),
|
||||
b: (...l: ElemArg[]) => _domKids(document.createElement('b'), l),
|
||||
img: (...l: ElemArg[]) => _domKids(document.createElement('img'), l),
|
||||
style: (...l: ElemArg[]) => _domKids(document.createElement('style'), l),
|
||||
search: (...l: ElemArg[]) => _domKids(document.createElement('search'), l),
|
||||
}
|
||||
const _attr = (k: string, v: string) => { const o: {[key: string]: string} = {}; o[k] = v; return {_attrs: o} }
|
||||
const attr = {
|
||||
title: (s: string) => _attr('title', s),
|
||||
value: (s: string) => _attr('value', s),
|
||||
type: (s: string) => _attr('type', s),
|
||||
tabindex: (s: string) => _attr('tabindex', s),
|
||||
src: (s: string) => _attr('src', s),
|
||||
placeholder: (s: string) => _attr('placeholder', s),
|
||||
href: (s: string) => _attr('href', s),
|
||||
checked: (s: string) => _attr('checked', s),
|
||||
selected: (s: string) => _attr('selected', s),
|
||||
id: (s: string) => _attr('id', s),
|
||||
datalist: (s: string) => _attr('datalist', s),
|
||||
rows: (s: string) => _attr('rows', s),
|
||||
target: (s: string) => _attr('target', s),
|
||||
rel: (s: string) => _attr('rel', s),
|
||||
required: (s: string) => _attr('required', s),
|
||||
multiple: (s: string) => _attr('multiple', s),
|
||||
download: (s: string) => _attr('download', s),
|
||||
disabled: (s: string) => _attr('disabled', s),
|
||||
draggable: (s: string) => _attr('draggable', s),
|
||||
rowspan: (s: string) => _attr('rowspan', s),
|
||||
colspan: (s: string) => _attr('colspan', s),
|
||||
for: (s: string) => _attr('for', s),
|
||||
role: (s: string) => _attr('role', s),
|
||||
arialabel: (s: string) => _attr('aria-label', s),
|
||||
arialive: (s: string) => _attr('aria-live', s),
|
||||
name: (s: string) => _attr('name', s)
|
||||
}
|
||||
const style = (x: {[k: string]: string | number}) => { return {_styles: x}}
|
||||
const prop = (x: {[k: string]: any}) => { return {_props: x}}
|
||||
return [dom, style, attr, prop]
|
||||
})()
|
||||
|
||||
// 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: any, efn: () => any): any[] => {
|
||||
const r: any[] = []
|
||||
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: string): (HTMLAnchorElement | string)[] => {
|
||||
// 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: string): HTMLElement => {
|
||||
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: string) => {
|
||||
// ../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: api.MessageAddress): string => {
|
||||
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: api.MessageAddress): string => {
|
||||
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: api.MessageAddress): string => {
|
||||
if (a.Name) {
|
||||
return a.Name
|
||||
}
|
||||
return '<' + a.User + '@' + a.Domain.ASCII + '>'
|
||||
}
|
||||
|
||||
// return just the email address.
|
||||
const formatEmailASCII = (a: api.MessageAddress): string => {
|
||||
return a.User + '@' + a.Domain.ASCII
|
||||
}
|
||||
|
||||
const equalAddress = (a: api.MessageAddress, b: api.MessageAddress) => {
|
||||
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: HTMLElement, mi: api.MessageItem, refineKeyword: null | ((kw: string) => Promise<void>)) => {
|
||||
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)
|
||||
}),
|
||||
) : [],
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
335
webmail/message.go
Normal file
335
webmail/message.go
Normal file
@ -0,0 +1,335 @@
|
||||
package webmail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// todo: we should have all needed information for messageItem in store.Message (perhaps some data in message.Part) for fast access, not having to parse the on-disk message file.
|
||||
|
||||
func messageItem(log *mlog.Log, m store.Message, state *msgState) (MessageItem, error) {
|
||||
pm, err := parsedMessage(log, m, state, false, true)
|
||||
if err != nil {
|
||||
return MessageItem{}, fmt.Errorf("parsing message %d for item: %v", m.ID, err)
|
||||
}
|
||||
// Clear largish unused data.
|
||||
m.MsgPrefix = nil
|
||||
m.ParsedBuf = nil
|
||||
return MessageItem{m, pm.envelope, pm.attachments, pm.isSigned, pm.isEncrypted, pm.firstLine}, nil
|
||||
}
|
||||
|
||||
// formatFirstLine returns a line the client can display next to the subject line
|
||||
// in a mailbox. It will replace quoted text, and any prefixing "On ... write:"
|
||||
// line with "[...]" so only new and useful information will be displayed.
|
||||
// Trailing signatures are not included.
|
||||
func formatFirstLine(r io.Reader) (string, error) {
|
||||
// We look quite a bit of lines ahead for trailing signatures with trailing empty lines.
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(r)
|
||||
ensureLines := func() {
|
||||
for len(lines) < 10 && scanner.Scan() {
|
||||
lines = append(lines, strings.TrimSpace(scanner.Text()))
|
||||
}
|
||||
}
|
||||
ensureLines()
|
||||
|
||||
isSnipped := func(s string) bool {
|
||||
return s == "[...]" || s == "..."
|
||||
}
|
||||
|
||||
nextLineQuoted := func(i int) bool {
|
||||
if i+1 < len(lines) && lines[i+1] == "" {
|
||||
i++
|
||||
}
|
||||
return i+1 < len(lines) && (strings.HasPrefix(lines[i+1], ">") || isSnipped(lines[i+1]))
|
||||
}
|
||||
|
||||
// remainder is signature if we see a line with only and minimum 2 dashes, and there are no more empty lines, and there aren't more than 5 lines left
|
||||
isSignature := func() bool {
|
||||
if len(lines) == 0 || !strings.HasPrefix(lines[0], "--") || strings.Trim(strings.TrimSpace(lines[0]), "-") != "" {
|
||||
return false
|
||||
}
|
||||
l := lines[1:]
|
||||
for len(l) > 0 && l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
}
|
||||
if len(l) >= 5 {
|
||||
return false
|
||||
}
|
||||
for _, line := range l {
|
||||
if line == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
result := ""
|
||||
|
||||
// Quick check for initial wrapped "On ... wrote:" line.
|
||||
if len(lines) > 3 && strings.HasPrefix(lines[0], "On ") && !strings.HasSuffix(lines[0], "wrote:") && strings.HasSuffix(lines[1], ":") && nextLineQuoted(1) {
|
||||
result = "[...]\n"
|
||||
lines = lines[3:]
|
||||
ensureLines()
|
||||
}
|
||||
|
||||
for ; len(lines) > 0 && !isSignature(); ensureLines() {
|
||||
line := lines[0]
|
||||
if strings.HasPrefix(line, ">") {
|
||||
if !strings.HasSuffix(result, "[...]\n") {
|
||||
result += "[...]\n"
|
||||
}
|
||||
lines = lines[1:]
|
||||
continue
|
||||
}
|
||||
if line == "" {
|
||||
lines = lines[1:]
|
||||
continue
|
||||
}
|
||||
// Check for a "On <date>, <person> wrote:", we require digits before a quoted
|
||||
// line, with an optional empty line in between. If we don't have any text yet, we
|
||||
// don't require the digits.
|
||||
if strings.HasSuffix(line, ":") && (strings.ContainsAny(line, "0123456789") || result == "") && nextLineQuoted(0) {
|
||||
if !strings.HasSuffix(result, "[...]\n") {
|
||||
result += "[...]\n"
|
||||
}
|
||||
lines = lines[1:]
|
||||
continue
|
||||
}
|
||||
// Skip snipping by author.
|
||||
if !(isSnipped(line) && strings.HasSuffix(result, "[...]\n")) {
|
||||
result += line + "\n"
|
||||
}
|
||||
lines = lines[1:]
|
||||
if len(result) > 250 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(result) > 250 {
|
||||
result = result[:230] + "..."
|
||||
}
|
||||
return result, scanner.Err()
|
||||
}
|
||||
|
||||
func parsedMessage(log *mlog.Log, m store.Message, state *msgState, full, msgitem bool) (pm ParsedMessage, rerr error) {
|
||||
if full || msgitem {
|
||||
if !state.ensurePart(m, true) {
|
||||
return pm, state.err
|
||||
}
|
||||
if full {
|
||||
pm.Part = *state.part
|
||||
}
|
||||
} else {
|
||||
if !state.ensurePart(m, false) {
|
||||
return pm, state.err
|
||||
}
|
||||
}
|
||||
|
||||
// todo: we should store this form in message.Part, requires a data structure update.
|
||||
|
||||
convertAddrs := func(l []message.Address) []MessageAddress {
|
||||
r := make([]MessageAddress, len(l))
|
||||
for i, a := range l {
|
||||
d, err := dns.ParseDomain(a.Host)
|
||||
log.Check(err, "parsing domain")
|
||||
if err != nil {
|
||||
d = dns.Domain{ASCII: a.Host}
|
||||
}
|
||||
r[i] = MessageAddress{a.Name, a.User, d}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if msgitem {
|
||||
env := MessageEnvelope{}
|
||||
if state.part.Envelope != nil {
|
||||
e := *state.part.Envelope
|
||||
env.Date = e.Date
|
||||
env.Subject = e.Subject
|
||||
env.InReplyTo = e.InReplyTo
|
||||
env.MessageID = e.MessageID
|
||||
env.From = convertAddrs(e.From)
|
||||
env.Sender = convertAddrs(e.Sender)
|
||||
env.ReplyTo = convertAddrs(e.ReplyTo)
|
||||
env.To = convertAddrs(e.To)
|
||||
env.CC = convertAddrs(e.CC)
|
||||
env.BCC = convertAddrs(e.BCC)
|
||||
}
|
||||
pm.envelope = env
|
||||
}
|
||||
|
||||
if full && state.part.BodyOffset > 0 {
|
||||
hdrs, err := state.part.Header()
|
||||
if err != nil {
|
||||
return ParsedMessage{}, fmt.Errorf("parsing headers: %v", err)
|
||||
}
|
||||
pm.Headers = hdrs
|
||||
|
||||
pm.ListReplyAddress = parseListPostAddress(hdrs.Get("List-Post"))
|
||||
} else {
|
||||
pm.Headers = map[string][]string{}
|
||||
}
|
||||
|
||||
pm.Texts = []string{}
|
||||
pm.attachments = []Attachment{}
|
||||
|
||||
// todo: how should we handle messages where a user prefers html, and we want to show it, but it's a DSN that also has textual-only parts? e.g. gmail's dsn where the first part is multipart/related with multipart/alternative, and second part is the regular message/delivery-status. we want to display both the html and the text.
|
||||
|
||||
var usePart func(p message.Part, index int, parent *message.Part, path []int)
|
||||
usePart = func(p message.Part, index int, parent *message.Part, path []int) {
|
||||
mt := p.MediaType + "/" + p.MediaSubType
|
||||
for i, sp := range p.Parts {
|
||||
if mt == "MULTIPART/SIGNED" && i >= 1 {
|
||||
continue
|
||||
}
|
||||
usePart(sp, i, &p, append(append([]int{}, path...), i))
|
||||
}
|
||||
switch mt {
|
||||
case "TEXT/PLAIN", "/":
|
||||
// Don't include if Content-Disposition attachment.
|
||||
if full || msgitem {
|
||||
// todo: should have this, and perhaps all content-* headers, preparsed in message.Part?
|
||||
h, err := p.Header()
|
||||
log.Check(err, "parsing attachment headers", mlog.Field("msgid", m.ID))
|
||||
cp := h.Get("Content-Disposition")
|
||||
if cp != "" {
|
||||
disp, params, err := mime.ParseMediaType(cp)
|
||||
log.Check(err, "parsing content-disposition", mlog.Field("cp", cp))
|
||||
if strings.EqualFold(disp, "attachment") {
|
||||
if full {
|
||||
name := p.ContentTypeParams["name"]
|
||||
if name == "" {
|
||||
name = params["filename"]
|
||||
}
|
||||
pm.attachments = append(pm.attachments, Attachment{path, name, p})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if full {
|
||||
buf, err := io.ReadAll(&moxio.LimitReader{R: p.ReaderUTF8OrBinary(), Limit: 2 * 1024 * 1024})
|
||||
if err != nil {
|
||||
rerr = fmt.Errorf("reading text part: %v", err)
|
||||
return
|
||||
}
|
||||
pm.Texts = append(pm.Texts, string(buf))
|
||||
}
|
||||
if msgitem && pm.firstLine == "" {
|
||||
pm.firstLine, rerr = formatFirstLine(p.ReaderUTF8OrBinary())
|
||||
if rerr != nil {
|
||||
rerr = fmt.Errorf("reading text for first line snippet: %v", rerr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case "TEXT/HTML":
|
||||
pm.HasHTML = true
|
||||
|
||||
default:
|
||||
// todo: see if there is a common nesting messages that are both signed and encrypted.
|
||||
if parent == nil && mt == "MULTIPART/SIGNED" {
|
||||
pm.isSigned = true
|
||||
}
|
||||
if parent == nil && mt == "MULTIPART/ENCRYPTED" {
|
||||
pm.isEncrypted = true
|
||||
}
|
||||
// todo: possibly do not include anything below multipart/alternative that starts with text/html, they may be cids. perhaps have a separate list of attachments for the text vs html version?
|
||||
if p.MediaType != "MULTIPART" {
|
||||
var parentct string
|
||||
if parent != nil {
|
||||
parentct = parent.MediaType + "/" + parent.MediaSubType
|
||||
}
|
||||
|
||||
// Recognize DSNs.
|
||||
if parentct == "MULTIPART/REPORT" && index == 1 && (mt == "MESSAGE/GLOBAL-DELIVERY-STATUS" || mt == "MESSAGE/DELIVERY-STATUS") {
|
||||
if full {
|
||||
buf, err := io.ReadAll(&moxio.LimitReader{R: p.ReaderUTF8OrBinary(), Limit: 1024 * 1024})
|
||||
if err != nil {
|
||||
rerr = fmt.Errorf("reading text part: %v", err)
|
||||
return
|
||||
}
|
||||
pm.Texts = append(pm.Texts, string(buf))
|
||||
}
|
||||
return
|
||||
}
|
||||
if parentct == "MULTIPART/REPORT" && index == 2 && (mt == "MESSAGE/GLOBAL-HEADERS" || mt == "TEXT/RFC822-HEADERS") {
|
||||
if full {
|
||||
buf, err := io.ReadAll(&moxio.LimitReader{R: p.ReaderUTF8OrBinary(), Limit: 1024 * 1024})
|
||||
if err != nil {
|
||||
rerr = fmt.Errorf("reading text part: %v", err)
|
||||
return
|
||||
}
|
||||
pm.Texts = append(pm.Texts, string(buf))
|
||||
}
|
||||
return
|
||||
}
|
||||
if parentct == "MULTIPART/REPORT" && index == 2 && (mt == "MESSAGE/GLOBAL" || mt == "TEXT/RFC822") {
|
||||
pm.attachments = append(pm.attachments, Attachment{path, "original.eml", p})
|
||||
return
|
||||
}
|
||||
|
||||
name, ok := p.ContentTypeParams["name"]
|
||||
if !ok && (full || msgitem) {
|
||||
// todo: should have this, and perhaps all content-* headers, preparsed in message.Part?
|
||||
h, err := p.Header()
|
||||
log.Check(err, "parsing attachment headers", mlog.Field("msgid", m.ID))
|
||||
cp := h.Get("Content-Disposition")
|
||||
if cp != "" {
|
||||
_, params, err := mime.ParseMediaType(cp)
|
||||
log.Check(err, "parsing content-disposition", mlog.Field("cp", cp))
|
||||
name = params["filename"]
|
||||
}
|
||||
}
|
||||
pm.attachments = append(pm.attachments, Attachment{path, name, p})
|
||||
}
|
||||
}
|
||||
}
|
||||
usePart(*state.part, -1, nil, []int{})
|
||||
|
||||
if rerr == nil {
|
||||
pm.ID = m.ID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// parses List-Post header, returning an address if it could be found, and nil otherwise.
|
||||
func parseListPostAddress(s string) *MessageAddress {
|
||||
/*
|
||||
Examples:
|
||||
List-Post: <mailto:list@host.com>
|
||||
List-Post: <mailto:moderator@host.com> (Postings are Moderated)
|
||||
List-Post: <mailto:moderator@host.com?subject=list%20posting>
|
||||
List-Post: NO (posting not allowed on this list)
|
||||
*/
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "<mailto:") {
|
||||
return nil
|
||||
}
|
||||
s = strings.TrimPrefix(s, "<mailto:")
|
||||
t := strings.SplitN(s, ">", 2)
|
||||
if len(t) != 2 {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(t[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
addr, err := smtp.ParseAddress(u.Opaque)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &MessageAddress{User: addr.Localpart.String(), Domain: addr.Domain}
|
||||
}
|
24
webmail/msg.html
Normal file
24
webmail/msg.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Message</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="noNeedlessFaviconRequestsPlease:" />
|
||||
<style>
|
||||
* { font-size: inherit; font-family: 'ubuntu', 'lato', sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
|
||||
table td, table th { padding: .25ex .5ex; }
|
||||
|
||||
.pad { padding: 1ex; }
|
||||
.scriptswitch { text-decoration: underline #dca053 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"><div style="padding: 1em">Loading...</div></div>
|
||||
|
||||
<!-- Load message data synchronously like in text.html, which needs it to generate a meaningful 'loaded' event, used for updating the iframe height. -->
|
||||
<script src="parsedmessage.js"></script>
|
||||
|
||||
<script src="../../msg.js"></script>
|
||||
</body>
|
||||
</html>
|
966
webmail/msg.js
Normal file
966
webmail/msg.js
Normal file
@ -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)'));
|
||||
}
|
67
webmail/msg.ts
Normal file
67
webmail/msg.ts
Normal file
@ -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)'))
|
||||
}
|
25
webmail/text.html
Normal file
25
webmail/text.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="noNeedlessFaviconRequestsPlease:" />
|
||||
<style>
|
||||
* { font-size: inherit; font-family: 'ubuntu', 'lato', sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
|
||||
.mono, .mono * { font-family: 'ubuntu mono', monospace; }
|
||||
.pad { padding: 1ex; }
|
||||
.scriptswitch { text-decoration: underline #dca053 2px; }
|
||||
.quoted1 { color: #03828f; }
|
||||
.quoted2 { color: #c7445c; }
|
||||
.quoted3 { color: #417c10; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page" style="opacity: .1">Loading...</div>
|
||||
|
||||
<!-- Load message data synchronously to generate a meaningful 'loaded' event, used by webmailmsg.html for updating the iframe height . -->
|
||||
<script src="parsedmessage.js"></script>
|
||||
|
||||
<script src="../../text.js"></script>
|
||||
</body>
|
||||
</html>
|
933
webmail/text.js
Normal file
933
webmail/text.js
Normal file
@ -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)'));
|
||||
});
|
19
webmail/text.ts
Normal file
19
webmail/text.ts
Normal file
@ -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)'))
|
||||
})
|
1789
webmail/view.go
Normal file
1789
webmail/view.go
Normal file
File diff suppressed because it is too large
Load Diff
440
webmail/view_test.go
Normal file
440
webmail/view_test.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
1065
webmail/webmail.go
Normal file
1065
webmail/webmail.go
Normal file
File diff suppressed because it is too large
Load Diff
75
webmail/webmail.html
Normal file
75
webmail/webmail.html
Normal file
@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mox Webmail</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1" />
|
||||
<link rel="icon" href="noNeedlessFaviconRequestsPlease:" />
|
||||
<style>
|
||||
* { font-size: inherit; font-family: 'ubuntu', 'lato', sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
|
||||
.mono, .mono * { font-family: 'ubuntu mono', monospace; }
|
||||
h1, h2 { margin-bottom: 1ex; }
|
||||
h1 { font-size: 1.1rem; }
|
||||
table td, table th { padding: .15em .25em; }
|
||||
[title] { text-decoration: underline; text-decoration-style: dotted; }
|
||||
.silenttitle { text-decoration: none; }
|
||||
fieldset { border: 0; }
|
||||
.loading { opacity: 0.1; animation: fadeout 1s ease-out; }
|
||||
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
|
||||
.button { display: inline-block; }
|
||||
button, .button { border-radius: .15em; background-color: #eee; border: 1px solid #888; padding: 0 .15em; color: inherit; /* for ipad, which shows blue or white text */ }
|
||||
button.active, .button.active, button.active:hover, .button.active:hover { background-color: gold; }
|
||||
button:hover, .button:hover { background-color: #ddd; }
|
||||
button.keyword:hover { background-color: #ffbd21; }
|
||||
button.keyword { cursor: pointer; }
|
||||
.btngroup button, .btngroup .button { border-radius: 0; border-right-width: 0; }
|
||||
.btngroup button:first-child, .btngroup .button:first-child { border-radius: .15em 0 0 .15em; }
|
||||
.btngroup button:last-child, .btngroup .button:last-child { border-radius: 0 .15em .15em 0; border-right-width: 1px; }
|
||||
iframe { border: 0; }
|
||||
.pad { padding: .5em; }
|
||||
.invert { filter: invert(100%); }
|
||||
.scriptswitch { text-decoration: underline #dca053 2px; }
|
||||
|
||||
.msgitem { display: flex; user-select: none; }
|
||||
.msgitemcell { padding: 2px 4px; }
|
||||
/* note: we assign widths to .msgitemflags, .msgitemfrom, .msgitemsubject, .msgitemage, and offsets through a stylesheet created in js */
|
||||
.msgitemage { text-align: right; }
|
||||
.msgitemfromtext { white-space: nowrap; overflow: hidden; }
|
||||
.msgitemsubjecttext { white-space: nowrap; overflow: hidden; }
|
||||
.msgitemsubjectsnippet { font-weight: normal; color: #666; }
|
||||
.msgitemmailbox { background-color: #999; color: white; border: 1px solid #777; padding: 0 .15em; margin-left: .15em; border-radius: .15em; font-weight: normal; font-size: .9em; white-space: nowrap; }
|
||||
.msgitemidentity { background-color: #999; color: white; border: 1px solid #777; padding: 0 .15em; margin-left: .15em; border-radius: .15em; font-weight: normal; font-size: .9em; white-space: nowrap; }
|
||||
|
||||
.topbar, .mailboxesbar { background-color: #fdfdf1; }
|
||||
.msglist { background-color: #f5ffff; }
|
||||
table.search td { padding: .25em; }
|
||||
.keyword { background-color: gold; color: black; border: 1px solid #8c7600; padding: 0 .15em; border-radius: .15em; font-weight: normal; font-size: .9em; margin: 0 .15em; white-space: nowrap; }
|
||||
.mailbox { padding: .15em .25em; }
|
||||
.mailboxitem { cursor: pointer; border-radius: .15em; }
|
||||
.mailboxitem.dropping { background-color: gold !important; }
|
||||
.mailboxitem:hover { background-color: #eee; }
|
||||
.mailboxitem.active { background: linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%); }
|
||||
.mailboxhoveronly { visibility: hidden; }
|
||||
.mailboxitem:hover .mailboxhoveronly, .mailboxitem:focus .mailboxhoveronly { visibility: visible; }
|
||||
.mailboxcollapse { visibility: hidden; }
|
||||
.mailboxitem:hover .mailboxcollapse, .mailboxitem:focus .mailboxcollapse { visibility: visible; }
|
||||
.msgitem { cursor: pointer; border-radius: .15em; border: 1px solid transparent; }
|
||||
.msgitem.focus { border: 1px solid #2685ff; }
|
||||
.msgitem:hover { background-color: #eee; }
|
||||
.msgitem.active { background: linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%); }
|
||||
.msgitemsubject { position: relative; }
|
||||
.msgitemflag { margin-right: 1px; font-weight: normal; font-size: .9em; }
|
||||
.quoted1 { color: #03828f; }
|
||||
.quoted2 { color: #c7445c; }
|
||||
.quoted3 { color: #417c10; }
|
||||
|
||||
.scrollparent { position: relative; }
|
||||
.yscroll { overflow-y: scroll; position: absolute; top: 0; bottom: 0; left: 0; right: 0; }
|
||||
.yscrollauto { overflow-y: auto; position: absolute; top: 0; bottom: 0; left: 0; right: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"><div style="padding: 1em">Loading...</div></div>
|
||||
<script>/* placeholder */</script>
|
||||
</body>
|
||||
</html>
|
4776
webmail/webmail.js
Normal file
4776
webmail/webmail.js
Normal file
File diff suppressed because it is too large
Load Diff
5095
webmail/webmail.ts
Normal file
5095
webmail/webmail.ts
Normal file
File diff suppressed because it is too large
Load Diff
540
webmail/webmail_test.go
Normal file
540
webmail/webmail_test.go
Normal file
@ -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 <mjl@mox.example>",
|
||||
To: "mox <mox@other.example>",
|
||||
Subject: "text message",
|
||||
Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"},
|
||||
}
|
||||
msgHTML = Message{
|
||||
From: "mjl <mjl@mox.example>",
|
||||
To: "mox <mox@other.example>",
|
||||
Subject: "html message",
|
||||
Part: Part{Type: "text/html", Content: `<html>the body <img src="cid:img1@mox.example" /></html>`},
|
||||
}
|
||||
msgAlt = Message{
|
||||
From: "mjl <mjl@mox.example>",
|
||||
To: "mox <mox@other.example>",
|
||||
Subject: "test",
|
||||
Headers: [][2]string{{"In-Reply-To", "<previous@host.example>"}},
|
||||
Part: Part{
|
||||
Type: "multipart/alternative",
|
||||
Parts: []Part{
|
||||
{Type: "text/plain", Content: "the body"},
|
||||
{Type: "text/html; charset=utf-8", Content: `<html>the body <img src="cid:img1@mox.example" /></html>`},
|
||||
},
|
||||
},
|
||||
}
|
||||
msgAltRel = Message{
|
||||
From: "mjl <mjl+altrel@mox.example>",
|
||||
To: "mox <mox+altrel@other.example>",
|
||||
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: `<html>the body <img src="cid:img1@mox.example" /></html>`,
|
||||
},
|
||||
{Type: `image/png`, Disposition: `inline; filename="test1.png"`, ID: "<img1@mox.example>", Content: `PNG...`, TransferEncoding: "base64"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
msgAttachments = Message{
|
||||
From: "mjl <mjl@mox.example>",
|
||||
To: "mox <mox@other.example>",
|
||||
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(``,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body></body></html>`)
|
||||
check(`<script>read localstorage</script>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body></body></html>`)
|
||||
check(`<a href="javascript:evil">click me</a>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
|
||||
check(`<a href="https://badsite" target="top">click me</a>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a href="https://badsite" target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
|
||||
check(`<a xlink:href="https://badsite">click me</a>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a xlink:href="https://badsite" target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
|
||||
check(`<a onclick="evil">click me</a>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
|
||||
check(`<iframe src="data:text/html;base64,evilhtml"></iframe>`,
|
||||
`<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><iframe></iframe></body></html>`)
|
||||
}
|
Reference in New Issue
Block a user