implement imap quota extension (rfc 9208)

we only have a "storage" limit. for total disk usage. we don't have a limit on
messages (count) or mailboxes (count). also not on total annotation size, but
we don't have support annotations at all at the moment.

we don't implement setquota. with rfc 9208 that's allowed. with the previous
quota rfc 2087 it wasn't.

the status command can now return "DELETED-STORAGE". which should be the disk
space that can be reclaimed by removing messages with the \Deleted flags.
however, it's not very likely clients set the \Deleted flag without expunging
the message immediately. we don't want to go through all messages to calculate
the sum of message sizes with the deleted flag. we also don't currently track
that in MailboxCount. so we just respond with "0". not compliant, but let's
wait until someone complains.

when returning quota information, it is not possible to give the current usage
when no limit is configured. clients implementing rfc 9208 should probably
conclude from the presence of QUOTA=RES-* capabilities (only in rfc 9208, not
in 2087) and the absence of those limits in quota responses (or the absence of
an untagged quota response at all) that a resource type doesn't have a limit.
thunderbird will claim there is no quota information when no limit was
configured, so we can probably conclude that it implements rfc 2087, but not
rfc 9208.

we now also show the usage & limit on the account page.

for issue #115 by pmarini
This commit is contained in:
Mechiel Lukkien 2024-03-11 14:02:35 +01:00
parent 6c92949f13
commit 4dea2de343
No known key found for this signature in database
17 changed files with 428 additions and 93 deletions

View file

@ -116,7 +116,8 @@ func (c *Conn) xrespText() RespText {
var knownCodes = stringMap( var knownCodes = stringMap(
// Without parameters. // Without parameters.
"ALERT", "PARSE", "READ-ONLY", "READ-WRITE", "TRYCREATE", "UIDNOTSTICKY", "UNAVAILABLE", "AUTHENTICATIONFAILED", "AUTHORIZATIONFAILED", "EXPIRED", "PRIVACYREQUIRED", "CONTACTADMIN", "NOPERM", "INUSE", "EXPUNGEISSUED", "CORRUPTION", "SERVERBUG", "CLIENTBUG", "CANNOT", "LIMIT", "OVERQUOTA", "ALREADYEXISTS", "NONEXISTENT", "NOTSAVED", "HASCHILDREN", "CLOSED", "UNKNOWN-CTE", "OVERQUOTA", "ALERT", "PARSE", "READ-ONLY", "READ-WRITE", "TRYCREATE", "UIDNOTSTICKY", "UNAVAILABLE", "AUTHENTICATIONFAILED", "AUTHORIZATIONFAILED", "EXPIRED", "PRIVACYREQUIRED", "CONTACTADMIN", "NOPERM", "INUSE", "EXPUNGEISSUED", "CORRUPTION", "SERVERBUG", "CLIENTBUG", "CANNOT", "LIMIT", "OVERQUOTA", "ALREADYEXISTS", "NONEXISTENT", "NOTSAVED", "HASCHILDREN", "CLOSED", "UNKNOWN-CTE",
"OVERQUOTA", // ../rfc/9208:472
// With parameters. // With parameters.
"BADCHARSET", "CAPABILITY", "PERMANENTFLAGS", "UIDNEXT", "UIDVALIDITY", "UNSEEN", "APPENDUID", "COPYUID", "BADCHARSET", "CAPABILITY", "PERMANENTFLAGS", "UIDNEXT", "UIDVALIDITY", "UNSEEN", "APPENDUID", "COPYUID",
"HIGHESTMODSEQ", "MODIFIED", "HIGHESTMODSEQ", "MODIFIED",
@ -367,7 +368,7 @@ func (c *Conn) xuntagged() Untagged {
if len(attrs) > 0 { if len(attrs) > 0 {
c.xspace() c.xspace()
} }
s := c.xword() s := c.xatom()
c.xspace() c.xspace()
S := strings.ToUpper(s) S := strings.ToUpper(s)
var num int64 var num int64
@ -396,6 +397,8 @@ func (c *Conn) xuntagged() Untagged {
} }
case "HIGHESTMODSEQ": case "HIGHESTMODSEQ":
num = c.xint64() num = c.xint64()
case "DELETED-STORAGE":
num = c.xint64()
default: default:
c.xerrorf("status: unknown attribute %q", s) c.xerrorf("status: unknown attribute %q", s)
} }
@ -489,6 +492,49 @@ func (c *Conn) xuntagged() Untagged {
c.xcrlf() c.xcrlf()
return UntaggedVanished{earlier, NumSet{Ranges: uids}} return UntaggedVanished{earlier, NumSet{Ranges: uids}}
// ../rfc/9208:668 ../2087:242
case "QUOTAROOT":
c.xspace()
c.xastring()
var roots []string
for c.take(' ') {
root := c.xastring()
roots = append(roots, root)
}
c.xcrlf()
return UntaggedQuotaroot(roots)
// ../rfc/9208:666 ../rfc/2087:239
case "QUOTA":
c.xspace()
root := c.xastring()
c.xspace()
c.xtake("(")
xresource := func() QuotaResource {
name := c.xatom()
c.xspace()
usage := c.xint64()
c.xspace()
limit := c.xint64()
return QuotaResource{QuotaResourceName(strings.ToUpper(name)), usage, limit}
}
seen := map[QuotaResourceName]bool{}
l := []QuotaResource{xresource()}
seen[l[0].Name] = true
for c.take(' ') {
res := xresource()
if seen[res.Name] {
c.xerrorf("duplicate resource name %q", res.Name)
}
seen[res.Name] = true
l = append(l, res)
}
c.xtake(")")
c.xcrlf()
return UntaggedQuota{root, l}
default: default:
v, err := strconv.ParseUint(w, 10, 32) v, err := strconv.ParseUint(w, 10, 32)
if err == nil { if err == nil {
@ -682,7 +728,7 @@ func (c *Conn) xatom() string {
var s string var s string
for { for {
b, err := c.readbyte() b, err := c.readbyte()
c.xcheckf(err, "read byte for flag") c.xcheckf(err, "read byte for atom")
if b <= ' ' || strings.IndexByte("(){%*\"\\]", b) >= 0 { if b <= ' ' || strings.IndexByte("(){%*\"\\]", b) >= 0 {
c.r.UnreadByte() c.r.UnreadByte()
if s == "" { if s == "" {

View file

@ -255,6 +255,37 @@ type UntaggedVanished struct {
UIDs NumSet UIDs NumSet
} }
// UntaggedQuotaroot lists the roots for which quota can be present.
type UntaggedQuotaroot []string
// UntaggedQuota holds the quota for a quota root.
type UntaggedQuota struct {
Root string
// Always has at least one. Any QUOTA=RES-* capability not mentioned has no limit
// or this quota root.
Resources []QuotaResource
}
// Resource types ../rfc/9208:533
// QuotaResourceName is the name of a resource type. More can be defined in the
// future and encountered in the wild. Always in upper case.
type QuotaResourceName string
const (
QuotaResourceStorage = "STORAGE"
QuotaResourceMesssage = "MESSAGE"
QuotaResourceMailbox = "MAILBOX"
QuotaResourceAnnotationStorage = "ANNOTATION-STORAGE"
)
type QuotaResource struct {
Name QuotaResourceName
Usage int64 // Currently in use. Count or disk size in 1024 byte blocks.
Limit int64 // Maximum allowed usage.
}
// ../rfc/2971:184 // ../rfc/2971:184
type UntaggedID map[string]string type UntaggedID map[string]string

View file

@ -436,9 +436,9 @@ func (p *parser) xmboxOrPat() ([]string, bool) {
return l, true return l, true
} }
// ../rfc/9051:7056, RECENT ../rfc/3501:5047, APPENDLIMIT ../rfc/7889:252, HIGHESTMODSEQ ../rfc/7162:2452 // ../rfc/9051:7056, RECENT ../rfc/3501:5047, APPENDLIMIT ../rfc/7889:252, HIGHESTMODSEQ ../rfc/7162:2452, DELETED-STORAGE ../rfc/9208:696
func (p *parser) xstatusAtt() string { func (p *parser) xstatusAtt() string {
w := p.xtakelist("MESSAGES", "UIDNEXT", "UIDVALIDITY", "UNSEEN", "DELETED", "SIZE", "RECENT", "APPENDLIMIT", "HIGHESTMODSEQ") w := p.xtakelist("MESSAGES", "UIDNEXT", "UIDVALIDITY", "UNSEEN", "DELETED-STORAGE", "DELETED", "SIZE", "RECENT", "APPENDLIMIT", "HIGHESTMODSEQ")
if w == "HIGHESTMODSEQ" { if w == "HIGHESTMODSEQ" {
// HIGHESTMODSEQ is a CONDSTORE-enabling parameter. ../rfc/7162:375 // HIGHESTMODSEQ is a CONDSTORE-enabling parameter. ../rfc/7162:375
p.conn.enabled[capCondstore] = true p.conn.enabled[capCondstore] = true

54
imapserver/quota_test.go Normal file
View file

@ -0,0 +1,54 @@
package imapserver
import (
"testing"
"github.com/mjl-/mox/imapclient"
)
func TestQuota1(t *testing.T) {
tc := start(t)
defer tc.close()
tc.client.Login("mjl@mox.example", password0)
// We don't implement setquota.
tc.transactf("bad", `setquota "" (STORAGE 123)`)
tc.transactf("bad", "getquotaroot") // Missing param.
tc.transactf("bad", "getquotaroot inbox bogus") // Too many params.
tc.transactf("bad", "getquota") // Missing param.
tc.transactf("bad", "getquota a b") // Too many params.
// tc does not have a limit.
tc.transactf("ok", "getquotaroot inbox")
tc.xuntagged(imapclient.UntaggedQuotaroot([]string{""}))
tc.transactf("no", "getquota bogusroot")
tc.transactf("ok", `getquota ""`)
tc.xuntagged()
// Check that we get a DELETED-STORAGE status attribute with value 0, also if
// messages are marked deleted. We don't go through the trouble.
tc.transactf("ok", "status inbox (DELETED-STORAGE)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"DELETED-STORAGE": 0}})
// tclimit does have a limit.
tclimit := startArgs(t, false, false, true, true, "limit")
defer tclimit.close()
tclimit.client.Login("limit@mox.example", password0)
tclimit.transactf("ok", "getquotaroot inbox")
tclimit.xuntagged(
imapclient.UntaggedQuotaroot([]string{""}),
imapclient.UntaggedQuota{Root: "", Resources: []imapclient.QuotaResource{{Name: imapclient.QuotaResourceStorage, Usage: 0, Limit: 1}}},
)
tclimit.transactf("ok", `getquota ""`)
tclimit.xuntagged(imapclient.UntaggedQuota{Root: "", Resources: []imapclient.QuotaResource{{Name: imapclient.QuotaResourceStorage, Usage: 0, Limit: 1}}})
tclimit.transactf("ok", "status inbox (DELETED-STORAGE)")
tclimit.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"DELETED-STORAGE": 0}})
}

View file

@ -154,12 +154,13 @@ var authFailDelay = time.Second // After authentication failure.
// CONDSTORE: ../rfc/7162:411 // CONDSTORE: ../rfc/7162:411
// QRESYNC: ../rfc/7162:1323 // QRESYNC: ../rfc/7162:1323
// STATUS=SIZE: ../rfc/8438 ../rfc/9051:8024 // STATUS=SIZE: ../rfc/8438 ../rfc/9051:8024
// QUOTA QUOTA=RES-STORAGE: ../rfc/9208:111
// //
// We always announce support for SCRAM PLUS-variants, also on connections without // We always announce support for SCRAM PLUS-variants, also on connections without
// TLS. The client should not be selecting PLUS variants on non-TLS connections, // TLS. The client should not be selecting PLUS variants on non-TLS connections,
// instead opting to do the bare SCRAM variant without indicating the server claims // instead opting to do the bare SCRAM variant without indicating the server claims
// to support the PLUS variant (skipping the server downgrade detection check). // to support the PLUS variant (skipping the server downgrade detection check).
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE" const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE QUOTA QUOTA=RES-STORAGE"
type conn struct { type conn struct {
cid int64 cid int64
@ -239,7 +240,7 @@ func stateCommands(cmds ...string) map[string]struct{} {
var ( var (
commandsStateAny = stateCommands("capability", "noop", "logout", "id") commandsStateAny = stateCommands("capability", "noop", "logout", "id")
commandsStateNotAuthenticated = stateCommands("starttls", "authenticate", "login") commandsStateNotAuthenticated = stateCommands("starttls", "authenticate", "login")
commandsStateAuthenticated = stateCommands("enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub") commandsStateAuthenticated = stateCommands("enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub", "getquotaroot", "getquota")
commandsStateSelected = stateCommands("close", "unselect", "expunge", "search", "fetch", "store", "copy", "move", "uid expunge", "uid search", "uid fetch", "uid store", "uid copy", "uid move") commandsStateSelected = stateCommands("close", "unselect", "expunge", "search", "fetch", "store", "copy", "move", "uid expunge", "uid search", "uid fetch", "uid store", "uid copy", "uid move")
) )
@ -256,20 +257,22 @@ var commands = map[string]func(c *conn, tag, cmd string, p *parser){
"login": (*conn).cmdLogin, "login": (*conn).cmdLogin,
// Authenticated and selected. // Authenticated and selected.
"enable": (*conn).cmdEnable, "enable": (*conn).cmdEnable,
"select": (*conn).cmdSelect, "select": (*conn).cmdSelect,
"examine": (*conn).cmdExamine, "examine": (*conn).cmdExamine,
"create": (*conn).cmdCreate, "create": (*conn).cmdCreate,
"delete": (*conn).cmdDelete, "delete": (*conn).cmdDelete,
"rename": (*conn).cmdRename, "rename": (*conn).cmdRename,
"subscribe": (*conn).cmdSubscribe, "subscribe": (*conn).cmdSubscribe,
"unsubscribe": (*conn).cmdUnsubscribe, "unsubscribe": (*conn).cmdUnsubscribe,
"list": (*conn).cmdList, "list": (*conn).cmdList,
"lsub": (*conn).cmdLsub, "lsub": (*conn).cmdLsub,
"namespace": (*conn).cmdNamespace, "namespace": (*conn).cmdNamespace,
"status": (*conn).cmdStatus, "status": (*conn).cmdStatus,
"append": (*conn).cmdAppend, "append": (*conn).cmdAppend,
"idle": (*conn).cmdIdle, "idle": (*conn).cmdIdle,
"getquotaroot": (*conn).cmdGetquotaroot,
"getquota": (*conn).cmdGetquota,
// Selected. // Selected.
"check": (*conn).cmdCheck, "check": (*conn).cmdCheck,
@ -2628,7 +2631,7 @@ func (c *conn) cmdStatus(tag, cmd string, p *parser) {
c.ok(tag, cmd) c.ok(tag, cmd)
} }
// Response syntax: ../rfc/9051:6681 ../rfc/9051:7070 ../rfc/9051:7059 ../rfc/3501:4834 // Response syntax: ../rfc/9051:6681 ../rfc/9051:7070 ../rfc/9051:7059 ../rfc/3501:4834 ../rfc/9208:712
func (c *conn) xstatusLine(tx *bstore.Tx, mb store.Mailbox, attrs []string) string { func (c *conn) xstatusLine(tx *bstore.Tx, mb store.Mailbox, attrs []string) string {
status := []string{} status := []string{}
for _, a := range attrs { for _, a := range attrs {
@ -2654,6 +2657,15 @@ func (c *conn) xstatusLine(tx *bstore.Tx, mb store.Mailbox, attrs []string) stri
case "HIGHESTMODSEQ": case "HIGHESTMODSEQ":
// ../rfc/7162:366 // ../rfc/7162:366
status = append(status, A, fmt.Sprintf("%d", c.xhighestModSeq(tx, mb.ID).Client())) status = append(status, A, fmt.Sprintf("%d", c.xhighestModSeq(tx, mb.ID).Client()))
case "DELETED-STORAGE":
// ../rfc/9208:394
// How much storage space could be reclaimed by expunging messages with the
// \Deleted flag. We could keep track of this number and return it efficiently.
// Calculating it each time can be slow, and we don't know if clients request it.
// Clients are not likely to set the deleted flag without immediately expunging
// nowadays. Let's wait for something to need it to go through the trouble, and
// always return 0 for now.
status = append(status, A, "0")
default: default:
xsyntaxErrorf("unknown attribute %q", a) xsyntaxErrorf("unknown attribute %q", a)
} }
@ -2792,7 +2804,7 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) {
ok, maxSize, err := c.account.CanAddMessageSize(tx, m.Size) ok, maxSize, err := c.account.CanAddMessageSize(tx, m.Size)
xcheckf(err, "checking quota") xcheckf(err, "checking quota")
if !ok { if !ok {
// ../rfc/9051:5155 // ../rfc/9051:5155 ../rfc/9208:472
xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize) xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize)
} }
@ -2872,6 +2884,87 @@ wait:
c.ok(tag, cmd) c.ok(tag, cmd)
} }
// Return the quota root for a mailbox name and any current quota's.
//
// State: Authenticated and selected.
func (c *conn) cmdGetquotaroot(tag, cmd string, p *parser) {
// Command: ../rfc/9208:278 ../rfc/2087:141
// Request syntax: ../rfc/9208:660 ../rfc/2087:233
p.xspace()
name := p.xmailbox()
p.xempty()
// This mailbox does not have to exist. Caller just wants to know which limits
// would apply. We only have one limit, so we don't use the name otherwise.
// ../rfc/9208:295
name = xcheckmailboxname(name, true)
// Get current usage for account.
var quota, size int64 // Account only has a quota if > 0.
c.account.WithRLock(func() {
quota = c.account.QuotaMessageSize()
if quota >= 0 {
c.xdbread(func(tx *bstore.Tx) {
du := store.DiskUsage{ID: 1}
err := tx.Get(&du)
xcheckf(err, "gather used quota")
size = du.MessageSize
})
}
})
// We only have one per account quota, we name it "" like the examples in the RFC.
// Response syntax: ../rfc/9208:668 ../rfc/2087:242
c.bwritelinef(`* QUOTAROOT %s ""`, astring(name).pack(c))
// We only write the quota response if there is a limit. The syntax doesn't allow
// an empty list, so we cannot send the current disk usage if there is no limit.
if quota > 0 {
// Response syntax: ../rfc/9208:666 ../rfc/2087:239
c.bwritelinef(`* QUOTA "" (STORAGE %d %d)`, (size+1024-1)/1024, (quota+1024-1)/1024)
}
c.ok(tag, cmd)
}
// Return the quota for a quota root.
//
// State: Authenticated and selected.
func (c *conn) cmdGetquota(tag, cmd string, p *parser) {
// Command: ../rfc/9208:245 ../rfc/2087:123
// Request syntax: ../rfc/9208:658 ../rfc/2087:231
p.xspace()
root := p.xastring()
p.xempty()
// We only have a per-account root called "".
if root != "" {
xuserErrorf("unknown quota root")
}
var quota, size int64
c.account.WithRLock(func() {
quota = c.account.QuotaMessageSize()
if quota > 0 {
c.xdbread(func(tx *bstore.Tx) {
du := store.DiskUsage{ID: 1}
err := tx.Get(&du)
xcheckf(err, "gather used quota")
size = du.MessageSize
})
}
})
// We only write the quota response if there is a limit. The syntax doesn't allow
// an empty list, so we cannot send the current disk usage if there is no limit.
if quota > 0 {
// Response syntax: ../rfc/9208:666 ../rfc/2087:239
c.bwritelinef(`* QUOTA "" (STORAGE %d %d)`, (size+1024-1)/1024, (quota+1024-1)/1024)
}
c.ok(tag, cmd)
}
// Check is an old deprecated command that is supposed to execute some mailbox consistency checks. // Check is an old deprecated command that is supposed to execute some mailbox consistency checks.
// //
// State: Selected // State: Selected
@ -3267,7 +3360,7 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
if ok, maxSize, err := c.account.CanAddMessageSize(tx, totalSize); err != nil { if ok, maxSize, err := c.account.CanAddMessageSize(tx, totalSize); err != nil {
xcheckf(err, "checking quota") xcheckf(err, "checking quota")
} else if !ok { } else if !ok {
// ../rfc/9051:5155 // ../rfc/9051:5155 ../rfc/9208:472
xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize) xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize)
} }
err = c.account.AddMessageSize(c.log, tx, totalSize) err = c.account.AddMessageSize(c.log, tx, totalSize)

View file

@ -229,7 +229,7 @@ https://www.iana.org/assignments/message-headers/message-headers.xhtml
8508 Roadmap - IMAP REPLACE Extension 8508 Roadmap - IMAP REPLACE Extension
8514 Roadmap - Internet Message Access Protocol (IMAP) - SAVEDATE Extension 8514 Roadmap - Internet Message Access Protocol (IMAP) - SAVEDATE Extension
8970 Roadmap - IMAP4 Extension: Message Preview Generation 8970 Roadmap - IMAP4 Extension: Message Preview Generation
9208 Roadmap - IMAP QUOTA Extension 9208 Partial - IMAP QUOTA Extension
9394 Roadmap - IMAP PARTIAL Extension for Paged SEARCH and FETCH 9394 Roadmap - IMAP PARTIAL Extension for Paged SEARCH and FETCH
5198 -? - Unicode Format for Network Interchange 5198 -? - Unicode Format for Network Interchange

View file

@ -21,6 +21,7 @@ import (
_ "embed" _ "embed"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa" "github.com/mjl-/sherpa"
"github.com/mjl-/sherpadoc" "github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom" "github.com/mjl-/sherpaprom"
@ -415,13 +416,34 @@ func (Account) SetPassword(ctx context.Context, password string) {
// and the destinations (keys are email addresses, or localparts to the default // and the destinations (keys are email addresses, or localparts to the default
// domain). todo: replace with a function that returns the whole account, when // domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields. // sherpadoc understands unnamed struct fields.
func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]config.Destination) { // StorageUsed is the sum of the sizes of all messages, in bytes.
// StorageLimit is the maximum storage that can be used, or 0 if there is no limit.
func (Account) Account(ctx context.Context) (fullName string, defaultDomain dns.Domain, destinations map[string]config.Destination, storageUsed, storageLimit int64) {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
accConf, ok := mox.Conf.Account(reqInfo.AccountName)
if !ok { acc, err := store.OpenAccount(log, reqInfo.AccountName)
xcheckf(ctx, errors.New("not found"), "looking up account") xcheckf(ctx, err, "open account")
} defer func() {
return accConf.FullName, accConf.DNSDomain, accConf.Destinations err := acc.Close()
log.Check(err, "closing account")
}()
var accConf config.Account
acc.WithRLock(func() {
accConf, _ = acc.Conf()
storageLimit = acc.QuotaMessageSize()
err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
du := store.DiskUsage{ID: 1}
err := tx.Get(&du)
storageUsed = du.MessageSize
return err
})
xcheckf(ctx, err, "get disk usage")
})
return accConf.FullName, accConf.DNSDomain, accConf.Destinations, storageUsed, storageLimit
} }
func (Account) AccountSaveFullName(ctx context.Context, fullName string) { func (Account) AccountSaveFullName(ctx context.Context, fullName string) {

View file

@ -309,10 +309,12 @@ var api;
// and the destinations (keys are email addresses, or localparts to the default // and the destinations (keys are email addresses, or localparts to the default
// domain). todo: replace with a function that returns the whole account, when // domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields. // sherpadoc understands unnamed struct fields.
// StorageUsed is the sum of the sizes of all messages, in bytes.
// StorageLimit is the maximum storage that can be used, or 0 if there is no limit.
async Account() { async Account() {
const fn = "Account"; const fn = "Account";
const paramTypes = []; const paramTypes = [];
const returnTypes = [["string"], ["Domain"], ["{}", "Destination"]]; const returnTypes = [["string"], ["Domain"], ["{}", "Destination"], ["int64"], ["int64"]];
const params = []; const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
@ -808,8 +810,26 @@ const green = '#1dea20';
const yellow = '#ffe400'; const yellow = '#ffe400';
const red = '#ff7443'; const red = '#ff7443';
const blue = '#8bc8ff'; const blue = '#8bc8ff';
const formatQuotaSize = (v) => {
if (v === 0) {
return '0';
}
const m = 1024 * 1024;
const g = m * 1024;
const t = g * 1024;
if (Math.floor(v / t) * t === v) {
return '' + (v / t) + 't';
}
else if (Math.floor(v / g) * g === v) {
return '' + (v / g) + 'g';
}
else if (Math.floor(v / m) * m === v) {
return '' + (v / m) + 'm';
}
return '' + v;
};
const index = async () => { const index = async () => {
const [accountFullName, domain, destinations] = await client.Account(); const [accountFullName, domain, destinations, storageUsed, storageLimit] = await client.Account();
let fullNameForm; let fullNameForm;
let fullNameFieldset; let fullNameFieldset;
let fullName; let fullName;
@ -954,7 +974,12 @@ const index = async () => {
finally { finally {
passwordFieldset.disabled = false; passwordFieldset.disabled = false;
} }
}), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.table(dom._class('slim'), dom.tr(dom.td('Maildirs in .tgz'), dom.td(exportForm('mail-export-maildir.tgz'))), dom.tr(dom.td('Maildirs in .zip'), dom.td(exportForm('mail-export-maildir.zip'))), dom.tr(dom.td('Mbox files in .tgz'), dom.td(exportForm('mail-export-mbox.tgz'))), dom.tr(dom.td('Mbox files in .zip'), dom.td(exportForm('mail-export-mbox.zip')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) { }), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [
dom.b('/', formatQuotaSize(storageLimit)),
' (',
'' + Math.floor(100 * storageUsed / storageLimit),
'%).',
] : [', no explicit limit is configured.']), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.table(dom._class('slim'), dom.tr(dom.td('Maildirs in .tgz'), dom.td(exportForm('mail-export-maildir.tgz'))), dom.tr(dom.td('Maildirs in .zip'), dom.td(exportForm('mail-export-maildir.zip'))), dom.tr(dom.td('Mbox files in .tgz'), dom.td(exportForm('mail-export-mbox.tgz'))), dom.tr(dom.td('Mbox files in .zip'), dom.td(exportForm('mail-export-mbox.zip')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const request = async () => { const request = async () => {

View file

@ -170,8 +170,25 @@ const yellow = '#ffe400'
const red = '#ff7443' const red = '#ff7443'
const blue = '#8bc8ff' const blue = '#8bc8ff'
const formatQuotaSize = (v: number) => {
if (v === 0) {
return '0'
}
const m = 1024*1024
const g = m*1024
const t = g*1024
if (Math.floor(v/t)*t === v) {
return ''+(v/t)+'t'
} else if (Math.floor(v/g)*g === v) {
return ''+(v/g)+'g'
} else if (Math.floor(v/m)*m === v) {
return ''+(v/m)+'m'
}
return ''+v
}
const index = async () => { const index = async () => {
const [accountFullName, domain, destinations] = await client.Account() const [accountFullName, domain, destinations, storageUsed, storageLimit] = await client.Account()
let fullNameForm: HTMLFormElement let fullNameForm: HTMLFormElement
let fullNameFieldset: HTMLFieldSetElement let fullNameFieldset: HTMLFieldSetElement
@ -418,6 +435,14 @@ const index = async () => {
}, },
), ),
dom.br(), dom.br(),
dom.h2('Disk usage'),
dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed/(1024*1024))*1024*1024)),
storageLimit > 0 ? [
dom.b('/', formatQuotaSize(storageLimit)),
' (',
''+Math.floor(100*storageUsed/storageLimit),
'%).',
] : [', no explicit limit is configured.']),
dom.h2('Export'), dom.h2('Export'),
dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'),
dom.table(dom._class('slim'), dom.table(dom._class('slim'),

View file

@ -216,7 +216,7 @@ func TestAccount(t *testing.T) {
api.SetPassword(ctx, "test1234") api.SetPassword(ctx, "test1234")
fullName, _, dests := api.Account(ctx) fullName, _, dests, _, _ := api.Account(ctx)
api.DestinationSave(ctx, "mjl@mox.example", dests["mjl@mox.example"], dests["mjl@mox.example"]) // todo: save modified value and compare it afterwards api.DestinationSave(ctx, "mjl@mox.example", dests["mjl@mox.example"], dests["mjl@mox.example"]) // todo: save modified value and compare it afterwards
api.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed api.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed

View file

@ -68,27 +68,39 @@
}, },
{ {
"Name": "Account", "Name": "Account",
"Docs": "Account returns information about the account: full name, the default domain,\nand the destinations (keys are email addresses, or localparts to the default\ndomain). todo: replace with a function that returns the whole account, when\nsherpadoc understands unnamed struct fields.", "Docs": "Account returns information about the account: full name, the default domain,\nand the destinations (keys are email addresses, or localparts to the default\ndomain). todo: replace with a function that returns the whole account, when\nsherpadoc understands unnamed struct fields.\nStorageUsed is the sum of the sizes of all messages, in bytes.\nStorageLimit is the maximum storage that can be used, or 0 if there is no limit.",
"Params": [], "Params": [],
"Returns": [ "Returns": [
{ {
"Name": "r0", "Name": "fullName",
"Typewords": [ "Typewords": [
"string" "string"
] ]
}, },
{ {
"Name": "r1", "Name": "defaultDomain",
"Typewords": [ "Typewords": [
"Domain" "Domain"
] ]
}, },
{ {
"Name": "r2", "Name": "destinations",
"Typewords": [ "Typewords": [
"{}", "{}",
"Destination" "Destination"
] ]
},
{
"Name": "storageUsed",
"Typewords": [
"int64"
]
},
{
"Name": "storageLimit",
"Typewords": [
"int64"
]
} }
] ]
}, },

View file

@ -129,12 +129,14 @@ export class Client {
// and the destinations (keys are email addresses, or localparts to the default // and the destinations (keys are email addresses, or localparts to the default
// domain). todo: replace with a function that returns the whole account, when // domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields. // sherpadoc understands unnamed struct fields.
async Account(): Promise<[string, Domain, { [key: string]: Destination }]> { // StorageUsed is the sum of the sizes of all messages, in bytes.
// StorageLimit is the maximum storage that can be used, or 0 if there is no limit.
async Account(): Promise<[string, Domain, { [key: string]: Destination }, number, number]> {
const fn: string = "Account" const fn: string = "Account"
const paramTypes: string[][] = [] const paramTypes: string[][] = []
const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"]] const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"],["int64"],["int64"]]
const params: any[] = [] const params: any[] = []
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, Domain, { [key: string]: Destination }] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, Domain, { [key: string]: Destination }, number, number]
} }
async AccountSaveFullName(fullName: string): Promise<void> { async AccountSaveFullName(fullName: string): Promise<void> {

View file

@ -1539,11 +1539,31 @@ func (Admin) Accounts(ctx context.Context) []string {
} }
// Account returns the parsed configuration of an account. // Account returns the parsed configuration of an account.
func (Admin) Account(ctx context.Context, account string) map[string]any { func (Admin) Account(ctx context.Context, account string) (accountConfig map[string]any, diskUsage int64) {
ac, ok := mox.Conf.Account(account) log := pkglog.WithContext(ctx)
if !ok {
xcheckuserf(ctx, errors.New("no such account"), "looking up account") acc, err := store.OpenAccount(log, account)
if err != nil && errors.Is(err, store.ErrAccountUnknown) {
xcheckuserf(ctx, err, "looking up account")
} }
xcheckf(ctx, err, "open account")
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()
var ac config.Account
acc.WithRLock(func() {
ac, _ = mox.Conf.Account(acc.Name)
err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
du := store.DiskUsage{ID: 1}
err := tx.Get(&du)
diskUsage = du.MessageSize
return err
})
xcheckf(ctx, err, "get disk usage")
})
// todo: should change sherpa to understand config.Account directly, with its anonymous structs. // todo: should change sherpa to understand config.Account directly, with its anonymous structs.
buf, err := json.Marshal(ac) buf, err := json.Marshal(ac)
@ -1552,7 +1572,7 @@ func (Admin) Account(ctx context.Context, account string) map[string]any {
err = json.Unmarshal(buf, &r) err = json.Unmarshal(buf, &r)
xcheckf(ctx, err, "unmarshal from json") xcheckf(ctx, err, "unmarshal from json")
return r return r, diskUsage
} }
// ConfigFiles returns the paths and contents of the static and dynamic configuration files. // ConfigFiles returns the paths and contents of the static and dynamic configuration files.

View file

@ -622,7 +622,7 @@ var api;
async Account(account) { async Account(account) {
const fn = "Account"; const fn = "Account";
const paramTypes = [["string"]]; const paramTypes = [["string"]];
const returnTypes = [["{}", "any"]]; const returnTypes = [["{}", "any"], ["int64"]];
const params = [account]; const params = [account];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
@ -1766,8 +1766,26 @@ const accounts = async () => {
accountModified = true; accountModified = true;
})), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); })), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.')))));
}; };
const formatQuotaSize = (v) => {
if (v === 0) {
return '0';
}
const m = 1024 * 1024;
const g = m * 1024;
const t = g * 1024;
if (Math.floor(v / t) * t === v) {
return '' + (v / t) + 't';
}
else if (Math.floor(v / g) * g === v) {
return '' + (v / g) + 'g';
}
else if (Math.floor(v / m) * m === v) {
return '' + (v / m) + 'm';
}
return '' + v;
};
const account = async (name) => { const account = async (name) => {
const [config, domains] = await Promise.all([ const [[config, diskUsage], domains] = await Promise.all([
client.Account(name), client.Account(name),
client.Domains(), client.Domains(),
]); ]);
@ -1803,30 +1821,11 @@ const account = async (name) => {
s = s.substring(0, s.length - 1); s = s.substring(0, s.length - 1);
} }
let v = parseInt(s); let v = parseInt(s);
console.log('x', s, v, mult, formatQuotaSize(v * mult));
if (isNaN(v) || origs !== formatQuotaSize(v * mult)) { if (isNaN(v) || origs !== formatQuotaSize(v * mult)) {
throw new Error('invalid number'); throw new Error('invalid number');
} }
return v * mult; return v * mult;
}; };
const formatQuotaSize = (v) => {
if (v === 0) {
return '0';
}
const m = 1024 * 1024;
const g = m * 1024;
const t = g * 1024;
if (Math.floor(v / t) * t === v) {
return '' + (v / t) + 't';
}
else if (Math.floor(v / g) * g === v) {
return '' + (v / g) + 'g';
}
else if (Math.floor(v / m) * m === v) {
return '' + (v / m) + 'm';
}
return '' + v;
};
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.div('Default domain: ', config.Domain ? dom.a(config.Domain, attr.href('#domains/' + config.Domain)) : '(none)'), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.div('Default domain: ', config.Domain ? dom.a(config.Domain, attr.href('#domains/' + config.Domain)) : '(none)'), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => {
let v = k; let v = k;
const t = k.split('@'); const t = k.split('@');
@ -1883,7 +1882,7 @@ const account = async (name) => {
} }
form.reset(); form.reset();
window.location.reload(); // todo: only reload the destinations window.location.reload(); // todo: only reload the destinations
}, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Limits'), dom.form(fieldsetLimits = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize)))), dom.submitbutton('Save')), async function submit(e) { }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Limits'), dom.form(fieldsetLimits = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.submitbutton('Save')), async function submit(e) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
fieldsetLimits.disabled = true; fieldsetLimits.disabled = true;

View file

@ -593,8 +593,25 @@ const accounts = async () => {
) )
} }
const formatQuotaSize = (v: number) => {
if (v === 0) {
return '0'
}
const m = 1024*1024
const g = m*1024
const t = g*1024
if (Math.floor(v/t)*t === v) {
return ''+(v/t)+'t'
} else if (Math.floor(v/g)*g === v) {
return ''+(v/g)+'g'
} else if (Math.floor(v/m)*m === v) {
return ''+(v/m)+'m'
}
return ''+v
}
const account = async (name: string) => { const account = async (name: string) => {
const [config, domains] = await Promise.all([ const [[config, diskUsage], domains] = await Promise.all([
client.Account(name), client.Account(name),
client.Domains(), client.Domains(),
]) ])
@ -631,30 +648,12 @@ const account = async (name: string) => {
s = s.substring(0, s.length-1) s = s.substring(0, s.length-1)
} }
let v = parseInt(s) let v = parseInt(s)
console.log('x', s, v, mult, formatQuotaSize(v*mult))
if (isNaN(v) || origs !== formatQuotaSize(v*mult)) { if (isNaN(v) || origs !== formatQuotaSize(v*mult)) {
throw new Error('invalid number') throw new Error('invalid number')
} }
return v*mult return v*mult
} }
const formatQuotaSize = (v: number) => {
if (v === 0) {
return '0'
}
const m = 1024*1024
const g = m*1024
const t = g*1024
if (Math.floor(v/t)*t === v) {
return ''+(v/t)+'t'
} else if (Math.floor(v/g)*g === v) {
return ''+(v/g)+'g'
} else if (Math.floor(v/m)*m === v) {
return ''+(v/m)+'m'
}
return ''+v
}
dom._kids(page, dom._kids(page,
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
@ -778,6 +777,7 @@ const account = async (name: string) => {
dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')),
dom.br(), dom.br(),
quotaMessageSize=dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), quotaMessageSize=dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))),
' Current usage is ', formatQuotaSize(Math.floor(diskUsage/(1024*1024))*1024*1024), '.',
), ),
dom.submitbutton('Save'), dom.submitbutton('Save'),
), ),

View file

@ -169,11 +169,17 @@
], ],
"Returns": [ "Returns": [
{ {
"Name": "r0", "Name": "accountConfig",
"Typewords": [ "Typewords": [
"{}", "{}",
"any" "any"
] ]
},
{
"Name": "diskUsage",
"Typewords": [
"int64"
]
} }
] ]
}, },

View file

@ -1080,12 +1080,12 @@ export class Client {
} }
// Account returns the parsed configuration of an account. // Account returns the parsed configuration of an account.
async Account(account: string): Promise<{ [key: string]: any }> { async Account(account: string): Promise<[{ [key: string]: any }, number]> {
const fn: string = "Account" const fn: string = "Account"
const paramTypes: string[][] = [["string"]] const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["{}","any"]] const returnTypes: string[][] = [["{}","any"],["int64"]]
const params: any[] = [account] const params: any[] = [account]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: any } return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [{ [key: string]: any }, number]
} }
// ConfigFiles returns the paths and contents of the static and dynamic configuration files. // ConfigFiles returns the paths and contents of the static and dynamic configuration files.