mirror of
https://github.com/mjl-/mox.git
synced 2025-07-15 03:34:35 +03:00
replace http basic auth for web interfaces with session cookie & csrf-based auth
the http basic auth we had was very simple to reason about, and to implement. but it has a major downside: there is no way to logout, browsers keep sending credentials. ideally, browsers themselves would show a button to stop sending credentials. a related downside: the http auth mechanism doesn't indicate for which server paths the credentials are. another downside: the original password is sent to the server with each request. though sending original passwords to web servers seems to be considered normal. our new approach uses session cookies, along with csrf values when we can. the sessions are server-side managed, automatically extended on each use. this makes it easy to invalidate sessions and keeps the frontend simpler (than with long- vs short-term sessions and refreshing). the cookies are httponly, samesite=strict, scoped to the path of the web interface. cookies are set "secure" when set over https. the cookie is set by a successful call to Login. a call to Logout invalidates a session. changing a password invalidates all sessions for a user, but keeps the session with which the password was changed alive. the csrf value is also random, and associated with the session cookie. the csrf must be sent as header for api calls, or as parameter for direct form posts (where we cannot set a custom header). rest-like calls made directly by the browser, e.g. for images, don't have a csrf protection. the csrf value is returned by the Login api call and stored in localstorage. api calls without credentials return code "user:noAuth", and with bad credentials return "user:badAuth". the api client recognizes this and triggers a login. after a login, all auth-failed api calls are automatically retried. only for "user:badAuth" is an error message displayed in the login form (e.g. session expired). in an ideal world, browsers would take care of most session management. a server would indicate authentication is needed (like http basic auth), and the browsers uses trusted ui to request credentials for the server & path. the browser could use safer mechanism than sending original passwords to the server, such as scram, along with a standard way to create sessions. for now, web developers have to do authentication themselves: from showing the login prompt, ensuring the right session/csrf cookies/localstorage/headers/etc are sent with each request. webauthn is a newer way to do authentication, perhaps we'll implement it in the future. though hardware tokens aren't an attractive option for many users, and it may be overkill as long as we still do old-fashioned authentication in smtp & imap where passwords can be sent to the server. for issue #58
This commit is contained in:
@ -2,6 +2,7 @@ package webmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@ -43,13 +44,16 @@ import (
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/smtpclient"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/webauth"
|
||||
)
|
||||
|
||||
//go:embed api.json
|
||||
var webmailapiJSON []byte
|
||||
|
||||
type Webmail struct {
|
||||
maxMessageSize int64 // From listener.
|
||||
maxMessageSize int64 // From listener.
|
||||
cookiePath string // From listener.
|
||||
isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
|
||||
}
|
||||
|
||||
func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
|
||||
@ -64,8 +68,8 @@ var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
|
||||
|
||||
var sherpaHandlerOpts *sherpa.HandlerOpts
|
||||
|
||||
func makeSherpaHandler(maxMessageSize int64) (http.Handler, error) {
|
||||
return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize}, &webmailDoc, sherpaHandlerOpts)
|
||||
func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
|
||||
return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -74,20 +78,59 @@ func init() {
|
||||
pkglog.Fatalx("creating sherpa prometheus collector", err)
|
||||
}
|
||||
|
||||
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}
|
||||
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
|
||||
// Just to validate.
|
||||
_, err = makeSherpaHandler(0)
|
||||
_, err = makeSherpaHandler(0, "", false)
|
||||
if err != nil {
|
||||
pkglog.Fatalx("sherpa handler", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
||||
// present in the call to Login.
|
||||
func (w Webmail) LoginPrep(ctx context.Context) string {
|
||||
log := pkglog.WithContext(ctx)
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
|
||||
var data [8]byte
|
||||
_, err := cryptorand.Read(data[:])
|
||||
xcheckf(ctx, err, "generate token")
|
||||
loginToken := base64.RawURLEncoding.EncodeToString(data[:])
|
||||
|
||||
webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
|
||||
|
||||
return loginToken
|
||||
}
|
||||
|
||||
// Login returns a session token for the credentials, or fails with error code
|
||||
// "user:badLogin". Call LoginPrep to get a loginToken.
|
||||
func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
|
||||
log := pkglog.WithContext(ctx)
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
|
||||
csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
|
||||
if _, ok := err.(*sherpa.Error); ok {
|
||||
panic(err)
|
||||
}
|
||||
xcheckf(ctx, err, "login")
|
||||
return csrfToken
|
||||
}
|
||||
|
||||
// Logout invalidates the session token.
|
||||
func (w Webmail) Logout(ctx context.Context) {
|
||||
log := pkglog.WithContext(ctx)
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
|
||||
err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
|
||||
xcheckf(ctx, err, "logout")
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (Webmail) Token(ctx context.Context) string {
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress)
|
||||
return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress, reqInfo.SessionToken)
|
||||
}
|
||||
|
||||
// Requests sends a new request for an open SSE connection. Any currently active
|
||||
|
@ -2,6 +2,57 @@
|
||||
"Name": "Webmail",
|
||||
"Docs": "",
|
||||
"Functions": [
|
||||
{
|
||||
"Name": "LoginPrep",
|
||||
"Docs": "LoginPrep returns a login token, and also sets it as cookie. Both must be\npresent in the call to Login.",
|
||||
"Params": [],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Login",
|
||||
"Docs": "Login returns a session token for the credentials, or fails with error code\n\"user:badLogin\". Call LoginPrep to get a loginToken.",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "loginToken",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "username",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "password",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"CSRFToken"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Logout",
|
||||
"Docs": "Logout invalidates the session token.",
|
||||
"Params": [],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "Token",
|
||||
"Docs": "Token returns a token to use for an SSE connection. A token can only be used for\na single SSE connection. Tokens are stored in memory for a maximum of 1 minute,\nwith at most 10 unused tokens (the most recently created) per account.",
|
||||
@ -2626,6 +2677,11 @@
|
||||
}
|
||||
],
|
||||
"Strings": [
|
||||
{
|
||||
"Name": "CSRFToken",
|
||||
"Docs": "",
|
||||
"Values": null
|
||||
},
|
||||
{
|
||||
"Name": "ThreadMode",
|
||||
"Docs": "",
|
||||
|
142
webmail/api.ts
142
webmail/api.ts
@ -479,6 +479,8 @@ export enum Validation {
|
||||
ValidationNone = 10, // E.g. No records.
|
||||
}
|
||||
|
||||
export type CSRFToken = string
|
||||
|
||||
export enum ThreadMode {
|
||||
ThreadOff = "off",
|
||||
ThreadOn = "on",
|
||||
@ -515,7 +517,7 @@ export enum SecurityResult {
|
||||
export type Localpart = string
|
||||
|
||||
export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":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,"RecipientSecurity":true,"Request":true,"SpecialUse":true,"SubmitMessage":true}
|
||||
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"Localpart":true,"SecurityResult":true,"ThreadMode":true}
|
||||
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"CSRFToken":true,"Localpart":true,"SecurityResult":true,"ThreadMode":true}
|
||||
export const intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true}
|
||||
export const types: TypenameMap = {
|
||||
"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"]}]},
|
||||
@ -559,6 +561,7 @@ export const types: TypenameMap = {
|
||||
"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":""}]},
|
||||
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
|
||||
"ThreadMode": {"Name":"ThreadMode","Docs":"","Values":[{"Name":"ThreadOff","Value":"off","Docs":""},{"Name":"ThreadOn","Value":"on","Docs":""},{"Name":"ThreadUnread","Value":"unread","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":""}]},
|
||||
"SecurityResult": {"Name":"SecurityResult","Docs":"","Values":[{"Name":"SecurityResultError","Value":"error","Docs":""},{"Name":"SecurityResultNo","Value":"no","Docs":""},{"Name":"SecurityResultYes","Value":"yes","Docs":""},{"Name":"SecurityResultUnknown","Value":"unknown","Docs":""}]},
|
||||
@ -607,6 +610,7 @@ export const parser = {
|
||||
UID: (v: any) => parse("UID", v) as UID,
|
||||
ModSeq: (v: any) => parse("ModSeq", v) as ModSeq,
|
||||
Validation: (v: any) => parse("Validation", v) as Validation,
|
||||
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
|
||||
ThreadMode: (v: any) => parse("ThreadMode", v) as ThreadMode,
|
||||
AttachmentType: (v: any) => parse("AttachmentType", v) as AttachmentType,
|
||||
SecurityResult: (v: any) => parse("SecurityResult", v) as SecurityResult,
|
||||
@ -616,14 +620,57 @@ export const parser = {
|
||||
let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true}
|
||||
|
||||
export class Client {
|
||||
constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) {
|
||||
if (!options) {
|
||||
this.options = defaultOptions
|
||||
}
|
||||
private baseURL: string
|
||||
public authState: AuthState
|
||||
public options: ClientOptions
|
||||
|
||||
constructor() {
|
||||
this.authState = {}
|
||||
this.options = {...defaultOptions}
|
||||
this.baseURL = this.options.baseURL || defaultBaseURL
|
||||
}
|
||||
|
||||
withAuthToken(token: string): Client {
|
||||
const c = new Client()
|
||||
c.authState.token = token
|
||||
c.options = this.options
|
||||
return c
|
||||
}
|
||||
|
||||
withOptions(options: ClientOptions): Client {
|
||||
return new Client(this.baseURL, { ...this.options, ...options })
|
||||
const c = new Client()
|
||||
c.authState = this.authState
|
||||
c.options = { ...this.options, ...options }
|
||||
return c
|
||||
}
|
||||
|
||||
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
||||
// present in the call to Login.
|
||||
async LoginPrep(): Promise<string> {
|
||||
const fn: string = "LoginPrep"
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["string"]]
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string
|
||||
}
|
||||
|
||||
// Login returns a session token for the credentials, or fails with error code
|
||||
// "user:badLogin". Call LoginPrep to get a loginToken.
|
||||
async Login(loginToken: string, username: string, password: string): Promise<CSRFToken> {
|
||||
const fn: string = "Login"
|
||||
const paramTypes: string[][] = [["string"],["string"],["string"]]
|
||||
const returnTypes: string[][] = [["CSRFToken"]]
|
||||
const params: any[] = [loginToken, username, password]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CSRFToken
|
||||
}
|
||||
|
||||
// Logout invalidates the session token.
|
||||
async Logout(): Promise<void> {
|
||||
const fn: string = "Logout"
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// Token returns a token to use for an SSE connection. A token can only be used for
|
||||
@ -634,7 +681,7 @@ export class Client {
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["string"]]
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string
|
||||
}
|
||||
|
||||
// Requests sends a new request for an open SSE connection. Any currently active
|
||||
@ -647,7 +694,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["Request"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [req]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// ParsedMessage returns enough to render the textual body of a message. It is
|
||||
@ -657,7 +704,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["int64"]]
|
||||
const returnTypes: string[][] = [["ParsedMessage"]]
|
||||
const params: any[] = [msgID]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
|
||||
}
|
||||
|
||||
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
||||
@ -671,7 +718,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["SubmitMessage"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [m]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MessageMove moves messages to another mailbox. If the message is already in
|
||||
@ -681,7 +728,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"],["int64"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs, mailboxID]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
|
||||
@ -690,7 +737,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
|
||||
@ -700,7 +747,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"],["[]","string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs, flaglist]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
|
||||
@ -709,7 +756,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"],["[]","string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs, flaglist]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MailboxCreate creates a new mailbox.
|
||||
@ -718,7 +765,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [name]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MailboxDelete deletes a mailbox and all its messages.
|
||||
@ -727,7 +774,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["int64"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [mailboxID]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
|
||||
@ -737,7 +784,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["int64"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [mailboxID]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
|
||||
@ -747,7 +794,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["int64"],["string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [mailboxID, newName]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// CompleteRecipient returns autocomplete matches for a recipient, returning the
|
||||
@ -758,7 +805,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = [["[]","string"],["bool"]]
|
||||
const params: any[] = [search]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string[] | null, boolean]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string[] | null, boolean]
|
||||
}
|
||||
|
||||
// MailboxSetSpecialUse sets the special use flags of a mailbox.
|
||||
@ -767,7 +814,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["Mailbox"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [mb]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// ThreadCollapse saves the ThreadCollapse field for the messages and its
|
||||
@ -778,7 +825,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"],["bool"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs, collapse]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// ThreadMute saves the ThreadMute field for the messages and their children.
|
||||
@ -788,7 +835,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["[]","int64"],["bool"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [messageIDs, mute]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
// RecipientSecurity looks up security properties of the address in the
|
||||
@ -798,7 +845,7 @@ export class Client {
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = [["RecipientSecurity"]]
|
||||
const params: any[] = [messageAddressee]
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
|
||||
}
|
||||
|
||||
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||
@ -807,7 +854,7 @@ export class Client {
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["EventStart"],["EventViewErr"],["EventViewReset"],["EventViewMsgs"],["EventViewChanges"],["ChangeMsgAdd"],["ChangeMsgRemove"],["ChangeMsgFlags"],["ChangeMsgThread"],["ChangeMailboxRemove"],["ChangeMailboxAdd"],["ChangeMailboxRename"],["ChangeMailboxCounts"],["ChangeMailboxSpecialUse"],["ChangeMailboxKeywords"],["Flags"]]
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]
|
||||
}
|
||||
}
|
||||
|
||||
@ -919,7 +966,7 @@ class verifier {
|
||||
|
||||
const ensure = (ok: boolean, expect: string): any => {
|
||||
if (!ok) {
|
||||
error('got ' + JSON.stringify(v) + ', expected ' + expect)
|
||||
error('got ' + JSON.stringify(v) + ', expected ' + expect)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@ -1060,6 +1107,7 @@ class verifier {
|
||||
|
||||
|
||||
export interface ClientOptions {
|
||||
baseURL?: string
|
||||
aborter?: {abort?: () => void}
|
||||
timeoutMsec?: number
|
||||
skipParamCheck?: boolean
|
||||
@ -1067,9 +1115,16 @@ export interface ClientOptions {
|
||||
slicesNullable?: boolean
|
||||
mapsNullable?: boolean
|
||||
nullableOptional?: boolean
|
||||
csrfHeader?: string
|
||||
login?: (reason: string) => Promise<string>
|
||||
}
|
||||
|
||||
const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
|
||||
export interface AuthState {
|
||||
token?: string // For csrf request header.
|
||||
loginPromise?: Promise<void> // To let multiple API calls wait for a single login attempt, not each opening a login popup.
|
||||
}
|
||||
|
||||
const _sherpaCall = async (baseURL: string, authState: AuthState, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
|
||||
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 })
|
||||
@ -1110,14 +1165,36 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
|
||||
await simulate(json)
|
||||
}
|
||||
|
||||
// Immediately create promise, so options.aborter is changed before returning.
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
let resolve1 = (v: { code: string, message: string }) => {
|
||||
const fn = (resolve: (v: any) => void, reject: (v: any) => void) => {
|
||||
let resolve1 = (v: any) => {
|
||||
resolve(v)
|
||||
resolve1 = () => { }
|
||||
reject1 = () => { }
|
||||
}
|
||||
let reject1 = (v: { code: string, message: string }) => {
|
||||
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
|
||||
const login = options.login
|
||||
if (!authState.loginPromise) {
|
||||
authState.loginPromise = new Promise((aresolve, areject) => {
|
||||
login(v.code === 'user:badAuth' ? (v.message || '') : '')
|
||||
.then((token) => {
|
||||
authState.token = token
|
||||
authState.loginPromise = undefined
|
||||
aresolve()
|
||||
}, (err: any) => {
|
||||
authState.loginPromise = undefined
|
||||
areject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
authState.loginPromise
|
||||
.then(() => {
|
||||
fn(resolve, reject)
|
||||
}, (err: any) => {
|
||||
reject(err)
|
||||
})
|
||||
return
|
||||
}
|
||||
reject(v)
|
||||
resolve1 = () => { }
|
||||
reject1 = () => { }
|
||||
@ -1132,6 +1209,9 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
|
||||
}
|
||||
}
|
||||
req.open('POST', url, true)
|
||||
if (options.csrfHeader && authState.token) {
|
||||
req.setRequestHeader(options.csrfHeader, authState.token)
|
||||
}
|
||||
if (options.timeoutMsec) {
|
||||
req.timeout = options.timeoutMsec
|
||||
}
|
||||
@ -1200,8 +1280,8 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
|
||||
} catch (err) {
|
||||
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' })
|
||||
}
|
||||
})
|
||||
return await promise
|
||||
}
|
||||
return await new Promise(fn)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
"github.com/mjl-/sherpa"
|
||||
|
||||
@ -19,7 +22,7 @@ import (
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func tneedError(t *testing.T, fn func()) {
|
||||
func tneedErrorCode(t *testing.T, code string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
t.Helper()
|
||||
@ -30,16 +33,20 @@ func tneedError(t *testing.T, fn func()) {
|
||||
}
|
||||
if err, ok := x.(*sherpa.Error); !ok {
|
||||
debug.PrintStack()
|
||||
t.Fatalf("expected sherpa user error, saw %#v", x)
|
||||
} else if err.Code != "user:error" {
|
||||
t.Fatalf("expected sherpa error, saw %#v", x)
|
||||
} else if err.Code != code {
|
||||
debug.PrintStack()
|
||||
t.Fatalf("expected sherpa user error, saw other sherpa error %#v", err)
|
||||
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
func tneedError(t *testing.T, fn func()) {
|
||||
tneedErrorCode(t, "user:error", 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) {
|
||||
@ -77,8 +84,55 @@ func TestAPI(t *testing.T) {
|
||||
tdeliver(t, acc, tm)
|
||||
}
|
||||
|
||||
api := Webmail{maxMessageSize: 1024 * 1024}
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
|
||||
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
|
||||
|
||||
// Test login, and rate limiter.
|
||||
loginReqInfo := requestInfo{"mjl@mox.example", "mjl", "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
|
||||
loginctx := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo)
|
||||
|
||||
// Missing login token.
|
||||
tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", "test1234") })
|
||||
|
||||
// Login with loginToken.
|
||||
loginCookie := &http.Cookie{Name: "webmaillogin"}
|
||||
loginCookie.Value = api.LoginPrep(loginctx)
|
||||
loginReqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
|
||||
|
||||
testLogin := func(username, password string, expErrCodes ...string) {
|
||||
t.Helper()
|
||||
|
||||
defer func() {
|
||||
x := recover()
|
||||
expErr := len(expErrCodes) > 0
|
||||
if (x != nil) != expErr {
|
||||
t.Fatalf("got %v, expected codes %v", x, expErrCodes)
|
||||
}
|
||||
if x == nil {
|
||||
return
|
||||
} else if err, ok := x.(*sherpa.Error); !ok {
|
||||
t.Fatalf("got %#v, expected at most *sherpa.Error", x)
|
||||
} else if !slices.Contains(expErrCodes, err.Code) {
|
||||
t.Fatalf("got error code %q, expected %v", err.Code, expErrCodes)
|
||||
}
|
||||
}()
|
||||
|
||||
api.Login(loginctx, loginCookie.Value, username, password)
|
||||
}
|
||||
testLogin("mjl@mox.example", "test1234")
|
||||
testLogin("mjl@mox.example", "bad", "user:loginFailed")
|
||||
testLogin("nouser@mox.example", "test1234", "user:loginFailed")
|
||||
testLogin("nouser@bad.example", "test1234", "user:loginFailed")
|
||||
for i := 3; i < 10; i++ {
|
||||
testLogin("bad@bad.example", "test1234", "user:loginFailed")
|
||||
}
|
||||
// Ensure rate limiter is triggered, also for slow tests.
|
||||
for i := 0; i < 10; i++ {
|
||||
testLogin("bad@bad.example", "test1234", "user:loginFailed", "user:error")
|
||||
}
|
||||
testLogin("bad@bad.example", "test1234", "user:error")
|
||||
|
||||
// Context with different IP, for clear rate limit history.
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
|
||||
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
|
||||
// FlagsAdd
|
||||
|
@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/mjl-/mox/metrics"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
type eventWriter struct {
|
||||
@ -26,6 +27,11 @@ type eventWriter struct {
|
||||
sync.Mutex
|
||||
closed bool
|
||||
|
||||
// Before writing an event, we check if session is still valid. If not, we send a
|
||||
// fatal error instead.
|
||||
accountName string
|
||||
sessionToken store.SessionToken
|
||||
|
||||
wrote bool // To be reset by user, set on write.
|
||||
events chan struct {
|
||||
name string // E.g. "start" for EventStart.
|
||||
@ -35,8 +41,8 @@ type eventWriter struct {
|
||||
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}
|
||||
func newEventWriter(out writeFlusher, waitMin, waitMax time.Duration, accountName string, sessionToken store.SessionToken) *eventWriter {
|
||||
return &eventWriter{out: out, waitMin: waitMin, waitMax: waitMax, accountName: accountName, sessionToken: sessionToken}
|
||||
}
|
||||
|
||||
// close shuts down the events channel, causing the goroutine (if created) to
|
||||
@ -72,6 +78,13 @@ 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 name != "fatalErr" {
|
||||
if _, err := store.SessionUse(ctx, log, ew.accountName, ew.sessionToken, ""); err != nil {
|
||||
ew.xsendEvent(ctx, log, "fatalErr", "session no longer valid")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (ew.waitMin > 0 || ew.waitMax > 0) && ew.events == nil {
|
||||
// First write on a connection with delay.
|
||||
ew.events = make(chan struct {
|
||||
|
125
webmail/msg.js
125
webmail/msg.js
@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
|
||||
name: (s) => _attr('name', s),
|
||||
min: (s) => _attr('min', s),
|
||||
max: (s) => _attr('max', s),
|
||||
action: (s) => _attr('action', s),
|
||||
method: (s) => _attr('method', s),
|
||||
};
|
||||
const style = (x) => { return { _styles: x }; };
|
||||
const prop = (x) => { return { _props: x }; };
|
||||
@ -269,7 +271,7 @@ var api;
|
||||
SecurityResult["SecurityResultUnknown"] = "unknown";
|
||||
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
|
||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": 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, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": 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"] }] },
|
||||
@ -313,6 +315,7 @@ var api;
|
||||
"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": "" }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "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": "" }] },
|
||||
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
|
||||
@ -360,6 +363,7 @@ var api;
|
||||
UID: (v) => api.parse("UID", v),
|
||||
ModSeq: (v) => api.parse("ModSeq", v),
|
||||
Validation: (v) => api.parse("Validation", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
ThreadMode: (v) => api.parse("ThreadMode", v),
|
||||
AttachmentType: (v) => api.parse("AttachmentType", v),
|
||||
SecurityResult: (v) => api.parse("SecurityResult", v),
|
||||
@ -368,16 +372,50 @@ var api;
|
||||
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
|
||||
class Client {
|
||||
baseURL;
|
||||
authState;
|
||||
options;
|
||||
constructor(baseURL = api.defaultBaseURL, options) {
|
||||
this.baseURL = baseURL;
|
||||
this.options = options;
|
||||
if (!options) {
|
||||
this.options = defaultOptions;
|
||||
}
|
||||
constructor() {
|
||||
this.authState = {};
|
||||
this.options = { ...defaultOptions };
|
||||
this.baseURL = this.options.baseURL || api.defaultBaseURL;
|
||||
}
|
||||
withAuthToken(token) {
|
||||
const c = new Client();
|
||||
c.authState.token = token;
|
||||
c.options = this.options;
|
||||
return c;
|
||||
}
|
||||
withOptions(options) {
|
||||
return new Client(this.baseURL, { ...this.options, ...options });
|
||||
const c = new Client();
|
||||
c.authState = this.authState;
|
||||
c.options = { ...this.options, ...options };
|
||||
return c;
|
||||
}
|
||||
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
||||
// present in the call to Login.
|
||||
async LoginPrep() {
|
||||
const fn = "LoginPrep";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Login returns a session token for the credentials, or fails with error code
|
||||
// "user:badLogin". Call LoginPrep to get a loginToken.
|
||||
async Login(loginToken, username, password) {
|
||||
const fn = "Login";
|
||||
const paramTypes = [["string"], ["string"], ["string"]];
|
||||
const returnTypes = [["CSRFToken"]];
|
||||
const params = [loginToken, username, password];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Logout invalidates the session token.
|
||||
async Logout() {
|
||||
const fn = "Logout";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// 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,
|
||||
@ -387,7 +425,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -399,7 +437,7 @@ var api;
|
||||
const paramTypes = [["Request"]];
|
||||
const returnTypes = [];
|
||||
const params = [req];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -408,7 +446,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [["ParsedMessage"]];
|
||||
const params = [msgID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -421,7 +459,7 @@ var api;
|
||||
const paramTypes = [["SubmitMessage"]];
|
||||
const returnTypes = [];
|
||||
const params = [m];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageMove moves messages to another mailbox. If the message is already in
|
||||
// the mailbox an error is returned.
|
||||
@ -430,7 +468,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
|
||||
async MessageDelete(messageIDs) {
|
||||
@ -438,7 +476,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -447,7 +485,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
|
||||
async FlagsClear(messageIDs, flaglist) {
|
||||
@ -455,7 +493,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxCreate creates a new mailbox.
|
||||
async MailboxCreate(name) {
|
||||
@ -463,7 +501,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [name];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxDelete deletes a mailbox and all its messages.
|
||||
async MailboxDelete(mailboxID) {
|
||||
@ -471,7 +509,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
|
||||
// its child mailboxes.
|
||||
@ -480,7 +518,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -489,7 +527,7 @@ var api;
|
||||
const paramTypes = [["int64"], ["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID, newName];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -499,7 +537,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["[]", "string"], ["bool"]];
|
||||
const params = [search];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxSetSpecialUse sets the special use flags of a mailbox.
|
||||
async MailboxSetSpecialUse(mb) {
|
||||
@ -507,7 +545,7 @@ var api;
|
||||
const paramTypes = [["Mailbox"]];
|
||||
const returnTypes = [];
|
||||
const params = [mb];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadCollapse saves the ThreadCollapse field for the messages and its
|
||||
// children. The messageIDs are typically thread roots. But not all roots
|
||||
@ -517,7 +555,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, collapse];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadMute saves the ThreadMute field for the messages and their children.
|
||||
// If messages are muted, they are also marked collapsed.
|
||||
@ -526,7 +564,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mute];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// RecipientSecurity looks up security properties of the address in the
|
||||
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
|
||||
@ -535,7 +573,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["RecipientSecurity"]];
|
||||
const params = [messageAddressee];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||
async SSETypes() {
|
||||
@ -543,7 +581,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
@ -731,7 +769,7 @@ var api;
|
||||
}
|
||||
}
|
||||
}
|
||||
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
|
||||
const _sherpaCall = async (baseURL, authState, 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 });
|
||||
@ -773,14 +811,36 @@ var api;
|
||||
if (json) {
|
||||
await simulate(json);
|
||||
}
|
||||
// Immediately create promise, so options.aborter is changed before returning.
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fn = (resolve, reject) => {
|
||||
let resolve1 = (v) => {
|
||||
resolve(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
};
|
||||
let reject1 = (v) => {
|
||||
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
|
||||
const login = options.login;
|
||||
if (!authState.loginPromise) {
|
||||
authState.loginPromise = new Promise((aresolve, areject) => {
|
||||
login(v.code === 'user:badAuth' ? (v.message || '') : '')
|
||||
.then((token) => {
|
||||
authState.token = token;
|
||||
authState.loginPromise = undefined;
|
||||
aresolve();
|
||||
}, (err) => {
|
||||
authState.loginPromise = undefined;
|
||||
areject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
authState.loginPromise
|
||||
.then(() => {
|
||||
fn(resolve, reject);
|
||||
}, (err) => {
|
||||
reject(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
@ -794,6 +854,9 @@ var api;
|
||||
};
|
||||
}
|
||||
req.open('POST', url, true);
|
||||
if (options.csrfHeader && authState.token) {
|
||||
req.setRequestHeader(options.csrfHeader, authState.token);
|
||||
}
|
||||
if (options.timeoutMsec) {
|
||||
req.timeout = options.timeoutMsec;
|
||||
}
|
||||
@ -867,8 +930,8 @@ var api;
|
||||
catch (err) {
|
||||
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
|
||||
}
|
||||
});
|
||||
return await promise;
|
||||
};
|
||||
return await new Promise(fn);
|
||||
};
|
||||
})(api || (api = {}));
|
||||
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
|
||||
|
125
webmail/text.js
125
webmail/text.js
@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
|
||||
name: (s) => _attr('name', s),
|
||||
min: (s) => _attr('min', s),
|
||||
max: (s) => _attr('max', s),
|
||||
action: (s) => _attr('action', s),
|
||||
method: (s) => _attr('method', s),
|
||||
};
|
||||
const style = (x) => { return { _styles: x }; };
|
||||
const prop = (x) => { return { _props: x }; };
|
||||
@ -269,7 +271,7 @@ var api;
|
||||
SecurityResult["SecurityResultUnknown"] = "unknown";
|
||||
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
|
||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": 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, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": 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"] }] },
|
||||
@ -313,6 +315,7 @@ var api;
|
||||
"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": "" }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "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": "" }] },
|
||||
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
|
||||
@ -360,6 +363,7 @@ var api;
|
||||
UID: (v) => api.parse("UID", v),
|
||||
ModSeq: (v) => api.parse("ModSeq", v),
|
||||
Validation: (v) => api.parse("Validation", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
ThreadMode: (v) => api.parse("ThreadMode", v),
|
||||
AttachmentType: (v) => api.parse("AttachmentType", v),
|
||||
SecurityResult: (v) => api.parse("SecurityResult", v),
|
||||
@ -368,16 +372,50 @@ var api;
|
||||
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
|
||||
class Client {
|
||||
baseURL;
|
||||
authState;
|
||||
options;
|
||||
constructor(baseURL = api.defaultBaseURL, options) {
|
||||
this.baseURL = baseURL;
|
||||
this.options = options;
|
||||
if (!options) {
|
||||
this.options = defaultOptions;
|
||||
}
|
||||
constructor() {
|
||||
this.authState = {};
|
||||
this.options = { ...defaultOptions };
|
||||
this.baseURL = this.options.baseURL || api.defaultBaseURL;
|
||||
}
|
||||
withAuthToken(token) {
|
||||
const c = new Client();
|
||||
c.authState.token = token;
|
||||
c.options = this.options;
|
||||
return c;
|
||||
}
|
||||
withOptions(options) {
|
||||
return new Client(this.baseURL, { ...this.options, ...options });
|
||||
const c = new Client();
|
||||
c.authState = this.authState;
|
||||
c.options = { ...this.options, ...options };
|
||||
return c;
|
||||
}
|
||||
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
||||
// present in the call to Login.
|
||||
async LoginPrep() {
|
||||
const fn = "LoginPrep";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Login returns a session token for the credentials, or fails with error code
|
||||
// "user:badLogin". Call LoginPrep to get a loginToken.
|
||||
async Login(loginToken, username, password) {
|
||||
const fn = "Login";
|
||||
const paramTypes = [["string"], ["string"], ["string"]];
|
||||
const returnTypes = [["CSRFToken"]];
|
||||
const params = [loginToken, username, password];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Logout invalidates the session token.
|
||||
async Logout() {
|
||||
const fn = "Logout";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// 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,
|
||||
@ -387,7 +425,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -399,7 +437,7 @@ var api;
|
||||
const paramTypes = [["Request"]];
|
||||
const returnTypes = [];
|
||||
const params = [req];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -408,7 +446,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [["ParsedMessage"]];
|
||||
const params = [msgID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -421,7 +459,7 @@ var api;
|
||||
const paramTypes = [["SubmitMessage"]];
|
||||
const returnTypes = [];
|
||||
const params = [m];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageMove moves messages to another mailbox. If the message is already in
|
||||
// the mailbox an error is returned.
|
||||
@ -430,7 +468,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
|
||||
async MessageDelete(messageIDs) {
|
||||
@ -438,7 +476,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -447,7 +485,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
|
||||
async FlagsClear(messageIDs, flaglist) {
|
||||
@ -455,7 +493,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxCreate creates a new mailbox.
|
||||
async MailboxCreate(name) {
|
||||
@ -463,7 +501,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [name];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxDelete deletes a mailbox and all its messages.
|
||||
async MailboxDelete(mailboxID) {
|
||||
@ -471,7 +509,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
|
||||
// its child mailboxes.
|
||||
@ -480,7 +518,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -489,7 +527,7 @@ var api;
|
||||
const paramTypes = [["int64"], ["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID, newName];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -499,7 +537,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["[]", "string"], ["bool"]];
|
||||
const params = [search];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxSetSpecialUse sets the special use flags of a mailbox.
|
||||
async MailboxSetSpecialUse(mb) {
|
||||
@ -507,7 +545,7 @@ var api;
|
||||
const paramTypes = [["Mailbox"]];
|
||||
const returnTypes = [];
|
||||
const params = [mb];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadCollapse saves the ThreadCollapse field for the messages and its
|
||||
// children. The messageIDs are typically thread roots. But not all roots
|
||||
@ -517,7 +555,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, collapse];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadMute saves the ThreadMute field for the messages and their children.
|
||||
// If messages are muted, they are also marked collapsed.
|
||||
@ -526,7 +564,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mute];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// RecipientSecurity looks up security properties of the address in the
|
||||
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
|
||||
@ -535,7 +573,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["RecipientSecurity"]];
|
||||
const params = [messageAddressee];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||
async SSETypes() {
|
||||
@ -543,7 +581,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
@ -731,7 +769,7 @@ var api;
|
||||
}
|
||||
}
|
||||
}
|
||||
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
|
||||
const _sherpaCall = async (baseURL, authState, 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 });
|
||||
@ -773,14 +811,36 @@ var api;
|
||||
if (json) {
|
||||
await simulate(json);
|
||||
}
|
||||
// Immediately create promise, so options.aborter is changed before returning.
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fn = (resolve, reject) => {
|
||||
let resolve1 = (v) => {
|
||||
resolve(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
};
|
||||
let reject1 = (v) => {
|
||||
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
|
||||
const login = options.login;
|
||||
if (!authState.loginPromise) {
|
||||
authState.loginPromise = new Promise((aresolve, areject) => {
|
||||
login(v.code === 'user:badAuth' ? (v.message || '') : '')
|
||||
.then((token) => {
|
||||
authState.token = token;
|
||||
authState.loginPromise = undefined;
|
||||
aresolve();
|
||||
}, (err) => {
|
||||
authState.loginPromise = undefined;
|
||||
areject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
authState.loginPromise
|
||||
.then(() => {
|
||||
fn(resolve, reject);
|
||||
}, (err) => {
|
||||
reject(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
@ -794,6 +854,9 @@ var api;
|
||||
};
|
||||
}
|
||||
req.open('POST', url, true);
|
||||
if (options.csrfHeader && authState.token) {
|
||||
req.setRequestHeader(options.csrfHeader, authState.token);
|
||||
}
|
||||
if (options.timeoutMsec) {
|
||||
req.timeout = options.timeoutMsec;
|
||||
}
|
||||
@ -867,8 +930,8 @@ var api;
|
||||
catch (err) {
|
||||
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
|
||||
}
|
||||
});
|
||||
return await promise;
|
||||
};
|
||||
return await new Promise(fn);
|
||||
};
|
||||
})(api || (api = {}));
|
||||
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
|
||||
|
@ -413,10 +413,11 @@ func sseGet(id int64, accountName string) (sse, bool) {
|
||||
// ssetoken is a temporary token that has not yet been used to start an SSE
|
||||
// connection. Created by Token, consumed by a new SSE connection.
|
||||
type ssetoken struct {
|
||||
token string // Uniquely generated.
|
||||
accName string
|
||||
address string // Address used to authenticate in call that created the token.
|
||||
validUntil time.Time
|
||||
token string // Uniquely generated.
|
||||
accName string
|
||||
address string // Address used to authenticate in call that created the token.
|
||||
sessionToken store.SessionToken // SessionToken that created this token, checked before sending updates.
|
||||
validUntil time.Time
|
||||
}
|
||||
|
||||
// ssetokens maintains unused tokens. We have just one, but it's a type so we
|
||||
@ -434,11 +435,11 @@ var sseTokens = ssetokens{
|
||||
|
||||
// xgenerate creates and saves a new token. It ensures no more than 10 tokens
|
||||
// per account exist, removing old ones if needed.
|
||||
func (x *ssetokens) xgenerate(ctx context.Context, accName, address string) string {
|
||||
func (x *ssetokens) xgenerate(ctx context.Context, accName, address string, sessionToken store.SessionToken) string {
|
||||
buf := make([]byte, 16)
|
||||
_, err := cryptrand.Read(buf)
|
||||
xcheckf(ctx, err, "generating token")
|
||||
st := ssetoken{base64.RawURLEncoding.EncodeToString(buf), accName, address, time.Now().Add(time.Minute)}
|
||||
st := ssetoken{base64.RawURLEncoding.EncodeToString(buf), accName, address, sessionToken, time.Now().Add(time.Minute)}
|
||||
|
||||
x.Lock()
|
||||
defer x.Unlock()
|
||||
@ -456,17 +457,17 @@ func (x *ssetokens) xgenerate(ctx context.Context, accName, address string) stri
|
||||
}
|
||||
|
||||
// check verifies a token, and consumes it if valid.
|
||||
func (x *ssetokens) check(token string) (string, string, bool, error) {
|
||||
func (x *ssetokens) check(token string) (string, string, store.SessionToken, bool, error) {
|
||||
x.Lock()
|
||||
defer x.Unlock()
|
||||
|
||||
st, ok := x.tokens[token]
|
||||
if !ok {
|
||||
return "", "", false, nil
|
||||
return "", "", "", false, nil
|
||||
}
|
||||
delete(x.tokens, token)
|
||||
if i := slices.Index(x.accountTokens[st.accName], st); i < 0 {
|
||||
return "", "", false, errors.New("internal error, could not find token in account")
|
||||
return "", "", "", false, errors.New("internal error, could not find token in account")
|
||||
} else {
|
||||
copy(x.accountTokens[st.accName][i:], x.accountTokens[st.accName][i+1:])
|
||||
x.accountTokens[st.accName] = x.accountTokens[st.accName][:len(x.accountTokens[st.accName])-1]
|
||||
@ -475,9 +476,9 @@ func (x *ssetokens) check(token string) (string, string, bool, error) {
|
||||
}
|
||||
}
|
||||
if time.Now().After(st.validUntil) {
|
||||
return "", "", false, nil
|
||||
return "", "", "", false, nil
|
||||
}
|
||||
return st.accName, st.address, true, nil
|
||||
return st.accName, st.address, st.sessionToken, true, nil
|
||||
}
|
||||
|
||||
// ioErr is panicked on i/o errors in serveEvents and handled in a defer.
|
||||
@ -506,7 +507,7 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
|
||||
http.Error(w, "400 - bad request - missing credentials", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accName, address, ok, err := sseTokens.check(token)
|
||||
accName, address, sessionToken, ok, err := sseTokens.check(token)
|
||||
if err != nil {
|
||||
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@ -515,6 +516,10 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
|
||||
http.Error(w, "400 - bad request - bad token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := store.SessionUse(ctx, log, accName, sessionToken, ""); err != nil {
|
||||
http.Error(w, "400 - bad request - bad session token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// We can simulate a slow SSE connection. It seems firefox doesn't slow down
|
||||
// incoming responses with its slow-network similation.
|
||||
@ -594,7 +599,7 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
|
||||
out = httpFlusher{out, flusher}
|
||||
|
||||
// We'll be writing outgoing SSE events through writer.
|
||||
writer = newEventWriter(out, waitMin, waitMax)
|
||||
writer = newEventWriter(out, waitMin, waitMax, accName, sessionToken)
|
||||
defer writer.close()
|
||||
|
||||
// Fetch initial data.
|
||||
|
@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -23,6 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func TestView(t *testing.T) {
|
||||
mox.LimitersInit()
|
||||
os.RemoveAll("../testdata/webmail/data")
|
||||
mox.Context = ctxbg
|
||||
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
|
||||
@ -39,10 +41,37 @@ func TestView(t *testing.T) {
|
||||
pkglog.Check(err, "closing account")
|
||||
}()
|
||||
|
||||
api := Webmail{maxMessageSize: 1024 * 1024}
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
|
||||
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/"}
|
||||
|
||||
respRec := httptest.NewRecorder()
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
|
||||
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
|
||||
// Prepare loginToken.
|
||||
loginCookie := &http.Cookie{Name: "webmaillogin"}
|
||||
loginCookie.Value = api.LoginPrep(ctx)
|
||||
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
|
||||
|
||||
api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range respRec.Result().Cookies() {
|
||||
if c.Name == "webmailsession" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("missing session cookie")
|
||||
}
|
||||
sct := strings.SplitN(sessionCookie.Value, " ", 2)
|
||||
if len(sct) != 2 || sct[1] != "mjl" {
|
||||
t.Fatalf("unexpected accountname %q in session cookie", sct[1])
|
||||
}
|
||||
sessionToken := store.SessionToken(sct[0])
|
||||
|
||||
reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, respRec, &http.Request{}}
|
||||
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
|
||||
api.MailboxCreate(ctx, "Lists/Go/Nuts")
|
||||
|
||||
var zerom store.Message
|
||||
@ -74,7 +103,7 @@ func TestView(t *testing.T) {
|
||||
// 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)))
|
||||
server := httptest.NewServer(http.HandlerFunc(Handler(1024*1024, "/webmail/", false)))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
@ -113,7 +142,7 @@ func TestView(t *testing.T) {
|
||||
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)
|
||||
t.Fatalf("got statuscode %d, expected %d (%s)", resp.StatusCode, http.StatusOK, readBody(resp.Body))
|
||||
}
|
||||
|
||||
evr := eventReader{t, bufio.NewReader(resp.Body), resp.Body}
|
||||
|
@ -38,25 +38,23 @@ import (
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/webaccount"
|
||||
"github.com/mjl-/mox/webauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
mox.LimitersInit()
|
||||
}
|
||||
|
||||
var pkglog = mlog.New("webmail", nil)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
// We pass the request to the sherpa handler so the TLS info can be used for
|
||||
// the Received header in submitted messages. Most API calls need just the
|
||||
// account name.
|
||||
type ctxKey string
|
||||
|
||||
var requestInfoCtxKey ctxKey = "requestInfo"
|
||||
|
||||
type requestInfo struct {
|
||||
LoginAddress string
|
||||
AccountName string
|
||||
SessionToken store.SessionToken
|
||||
Response http.ResponseWriter
|
||||
Request *http.Request // For Proto and TLS connection state during message submit.
|
||||
}
|
||||
|
||||
@ -171,19 +169,19 @@ func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
// Handler returns a handler for the webmail endpoints, customized for the max
|
||||
// message size coming from the listener.
|
||||
func Handler(maxMessageSize int64) func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := makeSherpaHandler(maxMessageSize)
|
||||
// message size coming from the listener and cookiePath.
|
||||
func Handler(maxMessageSize int64, cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := makeSherpaHandler(maxMessageSize, cookiePath, isForwarded)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
handle(sh, w, r)
|
||||
handle(sh, isForwarded, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
|
||||
|
||||
@ -195,17 +193,6 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// HTTP Basic authentication for all requests.
|
||||
loginAddress, accName := webaccount.CheckAuth(ctx, log, "webmail", w, r)
|
||||
if accName == "" {
|
||||
// Error response already sent.
|
||||
return
|
||||
}
|
||||
|
||||
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
|
||||
lw.AddAttr(slog.String("authaccount", accName))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
@ -230,13 +217,14 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
switch r.Method {
|
||||
case "GET", "HEAD":
|
||||
h := w.Header()
|
||||
h.Set("X-Frame-Options", "deny")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
webmailFile.Serve(ctx, log, w, r)
|
||||
default:
|
||||
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
|
||||
return
|
||||
case "GET", "HEAD":
|
||||
}
|
||||
|
||||
webmailFile.Serve(ctx, log, w, r)
|
||||
return
|
||||
|
||||
case "/msg.js", "/text.js":
|
||||
@ -258,9 +246,27 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// API calls.
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
reqInfo := requestInfo{loginAddress, accName, r}
|
||||
isAPI := strings.HasPrefix(r.URL.Path, "/api/")
|
||||
// Only allow POST for calls, they will not work cross-domain without CORS.
|
||||
if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
|
||||
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var loginAddress, accName string
|
||||
var sessionToken store.SessionToken
|
||||
// All other URLs, except the login endpoint require some authentication.
|
||||
if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
|
||||
var ok bool
|
||||
accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webmail", isForwarded, w, r, isAPI, isAPI, false)
|
||||
if !ok {
|
||||
// Response has been written already.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isAPI {
|
||||
reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
|
||||
ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
|
||||
apiHandler.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
@ -412,7 +418,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
headers(false, false, false)
|
||||
h.Set("Content-Type", "application/zip")
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
var subjectSlug string
|
||||
if p.Envelope != nil {
|
||||
s := p.Envelope.Subject
|
||||
@ -537,7 +543,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
params["charset"] = charset
|
||||
}
|
||||
h.Set("Content-Type", mime.FormatMediaType(ct, params))
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
|
||||
_, err := io.Copy(w, &moxio.AtReader{R: msgr})
|
||||
log.Check(err, "writing raw")
|
||||
@ -567,7 +573,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
allowSelfScript := true
|
||||
headers(sameorigin, loadExternal, allowSelfScript)
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
|
||||
path := filepath.FromSlash("webmail/msg.html")
|
||||
fallback := webmailmsgHTML
|
||||
@ -600,7 +606,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
headers(false, false, false)
|
||||
h.Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
|
||||
_, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson)
|
||||
log.Check(err, "writing parsedmessage.js")
|
||||
@ -632,7 +638,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
allowSelfScript := true
|
||||
headers(sameorigin, false, allowSelfScript)
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
|
||||
// We typically return the embedded file, but during development it's handy to load
|
||||
// from disk.
|
||||
@ -659,7 +665,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
headers(sameorigin, allowExternal, false)
|
||||
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
}
|
||||
|
||||
// todo: skip certain html parts? e.g. with content-disposition: attachment?
|
||||
@ -726,7 +732,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
|
||||
}
|
||||
h.Set("Content-Type", ct)
|
||||
h.Set("Cache-Control", "no-cache, max-age=0")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
if t[1] == "download" {
|
||||
name := tryDecodeParam(log, ap.ContentTypeParams["name"])
|
||||
if name == "" {
|
||||
|
@ -15,6 +15,7 @@ table td, table th { padding: .15em .25em; }
|
||||
.silenttitle { text-decoration: none; }
|
||||
fieldset { border: 0; }
|
||||
.loading { opacity: 0.1; animation: fadeout 1s ease-out; }
|
||||
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
|
||||
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
|
||||
.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 */ }
|
||||
@ -78,7 +79,7 @@ table.search td { padding: .25em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"><div style="padding: 1em">Loading...</div></div>
|
||||
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
|
||||
<script>/* placeholder */</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
|
||||
name: (s) => _attr('name', s),
|
||||
min: (s) => _attr('min', s),
|
||||
max: (s) => _attr('max', s),
|
||||
action: (s) => _attr('action', s),
|
||||
method: (s) => _attr('method', s),
|
||||
};
|
||||
const style = (x) => { return { _styles: x }; };
|
||||
const prop = (x) => { return { _props: x }; };
|
||||
@ -269,7 +271,7 @@ var api;
|
||||
SecurityResult["SecurityResultUnknown"] = "unknown";
|
||||
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
|
||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": 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, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
|
||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": 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"] }] },
|
||||
@ -313,6 +315,7 @@ var api;
|
||||
"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": "" }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "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": "" }] },
|
||||
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
|
||||
@ -360,6 +363,7 @@ var api;
|
||||
UID: (v) => api.parse("UID", v),
|
||||
ModSeq: (v) => api.parse("ModSeq", v),
|
||||
Validation: (v) => api.parse("Validation", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
ThreadMode: (v) => api.parse("ThreadMode", v),
|
||||
AttachmentType: (v) => api.parse("AttachmentType", v),
|
||||
SecurityResult: (v) => api.parse("SecurityResult", v),
|
||||
@ -368,16 +372,50 @@ var api;
|
||||
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
|
||||
class Client {
|
||||
baseURL;
|
||||
authState;
|
||||
options;
|
||||
constructor(baseURL = api.defaultBaseURL, options) {
|
||||
this.baseURL = baseURL;
|
||||
this.options = options;
|
||||
if (!options) {
|
||||
this.options = defaultOptions;
|
||||
}
|
||||
constructor() {
|
||||
this.authState = {};
|
||||
this.options = { ...defaultOptions };
|
||||
this.baseURL = this.options.baseURL || api.defaultBaseURL;
|
||||
}
|
||||
withAuthToken(token) {
|
||||
const c = new Client();
|
||||
c.authState.token = token;
|
||||
c.options = this.options;
|
||||
return c;
|
||||
}
|
||||
withOptions(options) {
|
||||
return new Client(this.baseURL, { ...this.options, ...options });
|
||||
const c = new Client();
|
||||
c.authState = this.authState;
|
||||
c.options = { ...this.options, ...options };
|
||||
return c;
|
||||
}
|
||||
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
||||
// present in the call to Login.
|
||||
async LoginPrep() {
|
||||
const fn = "LoginPrep";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Login returns a session token for the credentials, or fails with error code
|
||||
// "user:badLogin". Call LoginPrep to get a loginToken.
|
||||
async Login(loginToken, username, password) {
|
||||
const fn = "Login";
|
||||
const paramTypes = [["string"], ["string"], ["string"]];
|
||||
const returnTypes = [["CSRFToken"]];
|
||||
const params = [loginToken, username, password];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// Logout invalidates the session token.
|
||||
async Logout() {
|
||||
const fn = "Logout";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// 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,
|
||||
@ -387,7 +425,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["string"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -399,7 +437,7 @@ var api;
|
||||
const paramTypes = [["Request"]];
|
||||
const returnTypes = [];
|
||||
const params = [req];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -408,7 +446,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [["ParsedMessage"]];
|
||||
const params = [msgID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -421,7 +459,7 @@ var api;
|
||||
const paramTypes = [["SubmitMessage"]];
|
||||
const returnTypes = [];
|
||||
const params = [m];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageMove moves messages to another mailbox. If the message is already in
|
||||
// the mailbox an error is returned.
|
||||
@ -430,7 +468,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
|
||||
async MessageDelete(messageIDs) {
|
||||
@ -438,7 +476,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -447,7 +485,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
|
||||
async FlagsClear(messageIDs, flaglist) {
|
||||
@ -455,7 +493,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["[]", "string"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, flaglist];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxCreate creates a new mailbox.
|
||||
async MailboxCreate(name) {
|
||||
@ -463,7 +501,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [name];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxDelete deletes a mailbox and all its messages.
|
||||
async MailboxDelete(mailboxID) {
|
||||
@ -471,7 +509,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
|
||||
// its child mailboxes.
|
||||
@ -480,7 +518,7 @@ var api;
|
||||
const paramTypes = [["int64"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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.
|
||||
@ -489,7 +527,7 @@ var api;
|
||||
const paramTypes = [["int64"], ["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [mailboxID, newName];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...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
|
||||
@ -499,7 +537,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["[]", "string"], ["bool"]];
|
||||
const params = [search];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// MailboxSetSpecialUse sets the special use flags of a mailbox.
|
||||
async MailboxSetSpecialUse(mb) {
|
||||
@ -507,7 +545,7 @@ var api;
|
||||
const paramTypes = [["Mailbox"]];
|
||||
const returnTypes = [];
|
||||
const params = [mb];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadCollapse saves the ThreadCollapse field for the messages and its
|
||||
// children. The messageIDs are typically thread roots. But not all roots
|
||||
@ -517,7 +555,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, collapse];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// ThreadMute saves the ThreadMute field for the messages and their children.
|
||||
// If messages are muted, they are also marked collapsed.
|
||||
@ -526,7 +564,7 @@ var api;
|
||||
const paramTypes = [["[]", "int64"], ["bool"]];
|
||||
const returnTypes = [];
|
||||
const params = [messageIDs, mute];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// RecipientSecurity looks up security properties of the address in the
|
||||
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
|
||||
@ -535,7 +573,7 @@ var api;
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [["RecipientSecurity"]];
|
||||
const params = [messageAddressee];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||
async SSETypes() {
|
||||
@ -543,7 +581,7 @@ var api;
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
@ -731,7 +769,7 @@ var api;
|
||||
}
|
||||
}
|
||||
}
|
||||
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
|
||||
const _sherpaCall = async (baseURL, authState, 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 });
|
||||
@ -773,14 +811,36 @@ var api;
|
||||
if (json) {
|
||||
await simulate(json);
|
||||
}
|
||||
// Immediately create promise, so options.aborter is changed before returning.
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fn = (resolve, reject) => {
|
||||
let resolve1 = (v) => {
|
||||
resolve(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
};
|
||||
let reject1 = (v) => {
|
||||
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
|
||||
const login = options.login;
|
||||
if (!authState.loginPromise) {
|
||||
authState.loginPromise = new Promise((aresolve, areject) => {
|
||||
login(v.code === 'user:badAuth' ? (v.message || '') : '')
|
||||
.then((token) => {
|
||||
authState.token = token;
|
||||
authState.loginPromise = undefined;
|
||||
aresolve();
|
||||
}, (err) => {
|
||||
authState.loginPromise = undefined;
|
||||
areject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
authState.loginPromise
|
||||
.then(() => {
|
||||
fn(resolve, reject);
|
||||
}, (err) => {
|
||||
reject(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(v);
|
||||
resolve1 = () => { };
|
||||
reject1 = () => { };
|
||||
@ -794,6 +854,9 @@ var api;
|
||||
};
|
||||
}
|
||||
req.open('POST', url, true);
|
||||
if (options.csrfHeader && authState.token) {
|
||||
req.setRequestHeader(options.csrfHeader, authState.token);
|
||||
}
|
||||
if (options.timeoutMsec) {
|
||||
req.timeout = options.timeoutMsec;
|
||||
}
|
||||
@ -867,8 +930,8 @@ var api;
|
||||
catch (err) {
|
||||
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
|
||||
}
|
||||
});
|
||||
return await promise;
|
||||
};
|
||||
return await new Promise(fn);
|
||||
};
|
||||
})(api || (api = {}));
|
||||
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
|
||||
@ -1181,6 +1244,7 @@ const zindexes = {
|
||||
popover: '5',
|
||||
attachments: '5',
|
||||
shortcut: '6',
|
||||
login: '7',
|
||||
};
|
||||
// All logging goes through log() instead of console.log, except "should not happen" logging.
|
||||
let log = () => { };
|
||||
@ -1292,7 +1356,61 @@ let domainAddressConfigs = {};
|
||||
let rejectsMailbox = '';
|
||||
// Last known server version. For asking to reload.
|
||||
let lastServerVersion = '';
|
||||
const client = new api.Client();
|
||||
const login = async (reason) => {
|
||||
return new Promise((resolve, _) => {
|
||||
const origFocus = document.activeElement;
|
||||
let reasonElem;
|
||||
let fieldset;
|
||||
let username;
|
||||
let password;
|
||||
const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(style({ marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh' }), dom.form(async function submit(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
reasonElem.remove();
|
||||
try {
|
||||
fieldset.disabled = true;
|
||||
const loginToken = await client.LoginPrep();
|
||||
const token = await client.Login(loginToken, username.value, password.value);
|
||||
try {
|
||||
window.localStorage.setItem('webmailcsrftoken', token);
|
||||
}
|
||||
catch (err) {
|
||||
console.log('saving csrf token in localStorage', err);
|
||||
}
|
||||
root.remove();
|
||||
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
|
||||
origFocus.focus();
|
||||
}
|
||||
resolve(token);
|
||||
}
|
||||
catch (err) {
|
||||
console.log('login error', err);
|
||||
window.alert('Error: ' + errmsg(err));
|
||||
}
|
||||
finally {
|
||||
fieldset.disabled = false;
|
||||
}
|
||||
}, fieldset = dom.fieldset(dom.h1('Mail'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), username = dom.input(attr.required(''), attr.placeholder('jane@example.org'))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login')))))));
|
||||
document.body.appendChild(root);
|
||||
username.focus();
|
||||
});
|
||||
};
|
||||
const localStorageGet = (k) => {
|
||||
try {
|
||||
return window.localStorage.getItem(k);
|
||||
}
|
||||
catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const localStorageRemove = (k) => {
|
||||
try {
|
||||
return window.localStorage.removeItem(k);
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
};
|
||||
const client = new api.Client().withOptions({ csrfHeader: 'x-mox-csrf', login: login }).withAuthToken(localStorageGet('webmailcsrftoken') || '');
|
||||
// Link returns a clickable link with rel="noopener noreferrer".
|
||||
const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href);
|
||||
// Returns first own account address matching an address in l.
|
||||
@ -5289,6 +5407,7 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl
|
||||
const init = async () => {
|
||||
let connectionElem; // SSE connection status/error. Empty when connected.
|
||||
let layoutElem; // Select dropdown for layout.
|
||||
let loginAddressElem;
|
||||
let msglistscrollElem;
|
||||
let queryactivityElem; // We show ... when a query is active and data is forthcoming.
|
||||
// Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate.
|
||||
@ -5679,7 +5798,16 @@ const init = async () => {
|
||||
else {
|
||||
selectLayout(layoutElem.value);
|
||||
}
|
||||
}), ' ', dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), ' ', dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), ' ', link('https://github.com/mjl-/mox', 'mox')))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) {
|
||||
}), ' ', dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), ' ', dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), ' ', loginAddressElem = dom.span(), ' ', dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) {
|
||||
await withStatus('Logging out', client.Logout(), e.target);
|
||||
localStorageRemove('webmailcsrftoken');
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// Reload so all state is cleared from memory.
|
||||
window.location.reload();
|
||||
})))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) {
|
||||
startDrag(e, (e) => {
|
||||
mailboxesElem.style.width = Math.round(e.clientX) + 'px';
|
||||
topcomposeboxElem.style.width = Math.round(e.clientX) + 'px';
|
||||
@ -6029,6 +6157,7 @@ const init = async () => {
|
||||
connecting = false;
|
||||
sseID = start.SSEID;
|
||||
loginAddress = start.LoginAddress;
|
||||
dom._kids(loginAddressElem, loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII));
|
||||
const loginAddr = formatEmailASCII(loginAddress);
|
||||
accountAddresses = start.Addresses || [];
|
||||
accountAddresses.sort((a, b) => {
|
||||
|
@ -107,6 +107,7 @@ const zindexes = {
|
||||
popover: '5',
|
||||
attachments: '5',
|
||||
shortcut: '6',
|
||||
login: '7',
|
||||
}
|
||||
|
||||
// From HTML.
|
||||
@ -230,7 +231,89 @@ let rejectsMailbox: string = ''
|
||||
// Last known server version. For asking to reload.
|
||||
let lastServerVersion: string = ''
|
||||
|
||||
const client = new api.Client()
|
||||
const login = async (reason: string) => {
|
||||
return new Promise<string>((resolve: (v: string) => void, _) => {
|
||||
const origFocus = document.activeElement
|
||||
let reasonElem: HTMLElement
|
||||
let fieldset: HTMLFieldSetElement
|
||||
let username: HTMLInputElement
|
||||
let password: HTMLInputElement
|
||||
const root = dom.div(
|
||||
style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in'}),
|
||||
dom.div(
|
||||
reasonElem=reason ? dom.div(style({marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(),
|
||||
dom.div(
|
||||
style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh'}),
|
||||
dom.form(
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
reasonElem.remove()
|
||||
|
||||
try {
|
||||
fieldset.disabled = true
|
||||
const loginToken = await client.LoginPrep()
|
||||
const token = await client.Login(loginToken, username.value, password.value)
|
||||
try {
|
||||
window.localStorage.setItem('webmailcsrftoken', token)
|
||||
} catch (err) {
|
||||
console.log('saving csrf token in localStorage', err)
|
||||
}
|
||||
root.remove()
|
||||
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
|
||||
origFocus.focus()
|
||||
}
|
||||
resolve(token)
|
||||
} catch (err) {
|
||||
console.log('login error', err)
|
||||
window.alert('Error: ' + errmsg(err))
|
||||
} finally {
|
||||
fieldset.disabled = false
|
||||
}
|
||||
},
|
||||
fieldset=dom.fieldset(
|
||||
dom.h1('Mail'),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '2ex'}),
|
||||
dom.div('Email address', style({marginBottom: '.5ex'})),
|
||||
username=dom.input(attr.required(''), attr.placeholder('jane@example.org')),
|
||||
),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '2ex'}),
|
||||
dom.div('Password', style({marginBottom: '.5ex'})),
|
||||
password=dom.input(attr.type('password'), attr.required('')),
|
||||
),
|
||||
dom.div(
|
||||
style({textAlign: 'center'}),
|
||||
dom.submitbutton('Login'),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
document.body.appendChild(root)
|
||||
username.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const localStorageGet = (k: string): string | null => {
|
||||
try {
|
||||
return window.localStorage.getItem(k)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const localStorageRemove = (k: string) => {
|
||||
try {
|
||||
return window.localStorage.removeItem(k)
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
|
||||
const client = new api.Client().withOptions({csrfHeader: 'x-mox-csrf', login: login}).withAuthToken(localStorageGet('webmailcsrftoken') || '')
|
||||
|
||||
// Link returns a clickable link with rel="noopener noreferrer".
|
||||
const link = (href: string, anchorOpt?: string): HTMLElement => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href)
|
||||
@ -5350,6 +5433,7 @@ type listMailboxes = () => api.Mailbox[]
|
||||
const init = async () => {
|
||||
let connectionElem: HTMLElement // SSE connection status/error. Empty when connected.
|
||||
let layoutElem: HTMLSelectElement // Select dropdown for layout.
|
||||
let loginAddressElem: HTMLElement
|
||||
|
||||
let msglistscrollElem: HTMLElement
|
||||
let queryactivityElem: HTMLElement // We show ... when a query is active and data is forthcoming.
|
||||
@ -5915,7 +5999,18 @@ const init = async () => {
|
||||
' ',
|
||||
dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)),
|
||||
' ',
|
||||
link('https://github.com/mjl-/mox', 'mox'),
|
||||
loginAddressElem=dom.span(),
|
||||
' ',
|
||||
dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e: MouseEvent) {
|
||||
await withStatus('Logging out', client.Logout(), e.target! as HTMLButtonElement)
|
||||
localStorageRemove('webmailcsrftoken')
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
// Reload so all state is cleared from memory.
|
||||
window.location.reload()
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -6354,6 +6449,7 @@ const init = async () => {
|
||||
connecting = false
|
||||
sseID = start.SSEID
|
||||
loginAddress = start.LoginAddress
|
||||
dom._kids(loginAddressElem, loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII))
|
||||
const loginAddr = formatEmailASCII(loginAddress)
|
||||
accountAddresses = start.Addresses || []
|
||||
accountAddresses.sort((a, b) => {
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@ -20,14 +20,21 @@ import (
|
||||
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"github.com/mjl-/sherpa"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/webauth"
|
||||
)
|
||||
|
||||
var ctxbg = context.Background()
|
||||
|
||||
func init() {
|
||||
webauth.BadAuthDelay = 0
|
||||
}
|
||||
|
||||
func tcheck(t *testing.T, err error, msg string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
@ -267,6 +274,14 @@ func tdeliver(t *testing.T, acc *store.Account, tm *testmsg) {
|
||||
tm.ID = m.ID
|
||||
}
|
||||
|
||||
func readBody(r io.Reader) string {
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("read error: %s", err)
|
||||
}
|
||||
return fmt.Sprintf("data: %q", buf)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@ -288,13 +303,34 @@ func TestWebmail(t *testing.T) {
|
||||
pkglog.Check(err, "closing account")
|
||||
}()
|
||||
|
||||
api := Webmail{maxMessageSize: 1024 * 1024}
|
||||
apiHandler, err := makeSherpaHandler(api.maxMessageSize)
|
||||
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
|
||||
apiHandler, err := makeSherpaHandler(api.maxMessageSize, api.cookiePath, false)
|
||||
tcheck(t, err, "sherpa handler")
|
||||
|
||||
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
|
||||
respRec := httptest.NewRecorder()
|
||||
reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
|
||||
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
|
||||
// Prepare loginToken.
|
||||
loginCookie := &http.Cookie{Name: "webmaillogin"}
|
||||
loginCookie.Value = api.LoginPrep(ctx)
|
||||
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
|
||||
|
||||
csrfToken := api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range respRec.Result().Cookies() {
|
||||
if c.Name == "webmailsession" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("missing session cookie")
|
||||
}
|
||||
|
||||
reqInfo = requestInfo{"mjl@mox.example", "mjl", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
|
||||
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")
|
||||
@ -324,10 +360,12 @@ func TestWebmail(t *testing.T) {
|
||||
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))}
|
||||
cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
|
||||
cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
|
||||
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
|
||||
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
|
||||
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
|
||||
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
|
||||
|
||||
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
|
||||
t.Helper()
|
||||
@ -337,9 +375,10 @@ func TestWebmail(t *testing.T) {
|
||||
req.Header.Add(kv[0], kv[1])
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handle(apiHandler, rr, req)
|
||||
rr.Body = &bytes.Buffer{}
|
||||
handle(apiHandler, false, rr, req)
|
||||
if rr.Code != expStatusCode {
|
||||
t.Fatalf("got status %d, expected %d", rr.Code, expStatusCode)
|
||||
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
|
||||
}
|
||||
|
||||
resp := rr.Result()
|
||||
@ -353,41 +392,75 @@ func TestWebmail(t *testing.T) {
|
||||
check(resp)
|
||||
}
|
||||
}
|
||||
testHTTPAuth := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
|
||||
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
|
||||
t.Helper()
|
||||
testHTTP(method, path, httpHeaders{hdrAuthOK}, expStatusCode, expHeaders, check)
|
||||
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
|
||||
}
|
||||
testHTTPAuthREST := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
|
||||
t.Helper()
|
||||
testHTTP(method, path, httpHeaders{hdrSessionOK}, expStatusCode, expHeaders, check)
|
||||
}
|
||||
|
||||
userAuthError := func(resp *http.Response, expCode string) {
|
||||
t.Helper()
|
||||
|
||||
var response struct {
|
||||
Error *sherpa.Error `json:"error"`
|
||||
}
|
||||
err := json.NewDecoder(resp.Body).Decode(&response)
|
||||
tcheck(t, err, "parsing response as json")
|
||||
if response.Error == nil {
|
||||
t.Fatalf("expected sherpa error with code %s, no error", expCode)
|
||||
}
|
||||
if response.Error.Code != expCode {
|
||||
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
|
||||
}
|
||||
}
|
||||
badAuth := func(resp *http.Response) {
|
||||
t.Helper()
|
||||
userAuthError(resp, "user:badAuth")
|
||||
}
|
||||
noAuth := func(resp *http.Response) {
|
||||
t.Helper()
|
||||
userAuthError(resp, "user:noAuth")
|
||||
}
|
||||
|
||||
// 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", "/", httpHeaders{}, http.StatusOK, nil, nil)
|
||||
testHTTP("POST", "/", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
|
||||
testHTTP("GET", "/", httpHeaders{[2]string{"Accept-Encoding", "gzip"}}, http.StatusOK, httpHeaders{ctHTML, [2]string{"Content-Encoding", "gzip"}}, nil)
|
||||
testHTTP("GET", "/msg.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
|
||||
testHTTP("POST", "/msg.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
|
||||
testHTTP("GET", "/text.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
|
||||
testHTTP("POST", "/text.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, 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)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
|
||||
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
|
||||
testHTTPAuthAPI("GET", "/api/Bogus", http.StatusMethodNotAllowed, nil, nil)
|
||||
testHTTPAuthAPI("POST", "/api/Bogus", http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthAPI("POST", "/api/SSETypes", http.StatusOK, httpHeaders{ctJSON}, nil)
|
||||
|
||||
// Unknown.
|
||||
testHTTPAuth("GET", "/other", http.StatusNotFound, nil, nil)
|
||||
testHTTP("GET", "/other", httpHeaders{}, http.StatusForbidden, 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)
|
||||
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), nil, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFOK}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", 0), http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", testmsgs[len(testmsgs)-1].ID+1), http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/view/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus/0", inboxMinimal.ID), http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", "/msg/", http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("POST", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), http.StatusMethodNotAllowed, nil, nil)
|
||||
|
||||
// HTTP message: attachments.zip
|
||||
ctZip := [2]string{"Content-Type", "application/zip"}
|
||||
@ -415,39 +488,39 @@ func TestWebmail(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
|
||||
testHTTPAuth("GET", pathInboxMinimal+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
|
||||
testHTTPAuthREST("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) {
|
||||
testHTTPAuthREST("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) {
|
||||
testHTTPAuthREST("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)
|
||||
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
mox.LimitersInit() // Reset, for too many failures.
|
||||
}
|
||||
|
||||
@ -486,37 +559,46 @@ func TestWebmail(t *testing.T) {
|
||||
"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)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/msghtml", http.StatusOK, httpHeaders{ctHTML, cspMsgHTML}, nil)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/html?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLSameOrigin}, nil)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTPAuthREST("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)
|
||||
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0", http.StatusOK, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.0", http.StatusOK, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.1", http.StatusOK, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.2", http.StatusNotFound, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/1", http.StatusNotFound, nil, nil)
|
||||
}
|
||||
|
||||
// Logout invalidates the session. Must work exactly once.
|
||||
// Normally the generic /api/ auth check returns a user error. We bypass it and
|
||||
// check for the server error.
|
||||
sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
|
||||
reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, httptest.NewRecorder(), &http.Request{RemoteAddr: "127.0.0.1:1234"}}
|
||||
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
|
||||
api.Logout(ctx)
|
||||
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
|
||||
}
|
||||
|
||||
func TestSanitize(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user