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
17 changed files with 428 additions and 93 deletions

View File

@ -21,6 +21,7 @@ import (
_ "embed"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
"github.com/mjl-/sherpadoc"
"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
// domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields.
func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]config.Destination) {
// 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)
accConf, ok := mox.Conf.Account(reqInfo.AccountName)
if !ok {
xcheckf(ctx, errors.New("not found"), "looking up account")
}
return accConf.FullName, accConf.DNSDomain, accConf.Destinations
acc, err := store.OpenAccount(log, reqInfo.AccountName)
xcheckf(ctx, err, "open account")
defer func() {
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) {

View File

@ -309,10 +309,12 @@ var api;
// and the destinations (keys are email addresses, or localparts to the default
// domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields.
// 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() {
const fn = "Account";
const paramTypes = [];
const returnTypes = [["string"], ["Domain"], ["{}", "Destination"]];
const returnTypes = [["string"], ["Domain"], ["{}", "Destination"], ["int64"], ["int64"]];
const 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 red = '#ff7443';
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 [accountFullName, domain, destinations] = await client.Account();
const [accountFullName, domain, destinations, storageUsed, storageLimit] = await client.Account();
let fullNameForm;
let fullNameFieldset;
let fullName;
@ -954,7 +974,12 @@ const index = async () => {
finally {
passwordFieldset.disabled = false;
}
}), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.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.stopPropagation();
const request = async () => {

View File

@ -170,8 +170,25 @@ const yellow = '#ffe400'
const red = '#ff7443'
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 [accountFullName, domain, destinations] = await client.Account()
const [accountFullName, domain, destinations, storageUsed, storageLimit] = await client.Account()
let fullNameForm: HTMLFormElement
let fullNameFieldset: HTMLFieldSetElement
@ -418,6 +435,14 @@ const index = async () => {
},
),
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'),

View File

@ -216,7 +216,7 @@ func TestAccount(t *testing.T) {
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.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed

View File

@ -68,27 +68,39 @@
},
{
"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": [],
"Returns": [
{
"Name": "r0",
"Name": "fullName",
"Typewords": [
"string"
]
},
{
"Name": "r1",
"Name": "defaultDomain",
"Typewords": [
"Domain"
]
},
{
"Name": "r2",
"Name": "destinations",
"Typewords": [
"{}",
"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
// domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields.
async Account(): Promise<[string, Domain, { [key: string]: Destination }]> {
// 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 paramTypes: string[][] = []
const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"]]
const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"],["int64"],["int64"]]
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> {