From ad26fd265d58fd3de358dce916eb76ce1de49203 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 30 Jan 2025 11:50:52 +0100 Subject: [PATCH] webmail: add button to mark a mailbox and its children as read this sets the seen flag on all messages in the mailbox and its children. --- webmail/api.go | 10 ++++++++ webmail/api.json | 14 ++++++++++++ webmail/api.ts | 10 ++++++++ webmail/api_test.go | 5 ++++ webmail/msg.js | 9 ++++++++ webmail/text.js | 9 ++++++++ webmail/webmail.js | 15 +++++++++++- webmail/webmail.ts | 7 ++++++ webops/xops.go | 56 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 134 insertions(+), 1 deletion(-) diff --git a/webmail/api.go b/webmail/api.go index 598fa02..6cbff87 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -1195,6 +1195,16 @@ func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []st xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist) } +// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are +// not automatically included, they must explicitly be included in the list of IDs. +func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + acc := reqInfo.Account + log := reqInfo.Log + + xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs) +} + // MailboxCreate creates a new mailbox. func (Webmail) MailboxCreate(ctx context.Context, name string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) diff --git a/webmail/api.json b/webmail/api.json index 0eb7226..31ff804 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -247,6 +247,20 @@ ], "Returns": [] }, + { + "Name": "MailboxesMarkRead", + "Docs": "MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are\nnot automatically included, they must explicitly be included in the list of IDs.", + "Params": [ + { + "Name": "mailboxIDs", + "Typewords": [ + "[]", + "int64" + ] + } + ], + "Returns": [] + }, { "Name": "MailboxCreate", "Docs": "MailboxCreate creates a new mailbox.", diff --git a/webmail/api.ts b/webmail/api.ts index d3eee30..93b5d9b 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -875,6 +875,16 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + // MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are + // not automatically included, they must explicitly be included in the list of IDs. + async MailboxesMarkRead(mailboxIDs: number[] | null): Promise { + const fn: string = "MailboxesMarkRead" + const paramTypes: string[][] = [["[]","int64"]] + const returnTypes: string[][] = [] + const params: any[] = [mailboxIDs] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + // MailboxCreate creates a new mailbox. async MailboxCreate(name: string): Promise { const fn: string = "MailboxCreate" diff --git a/webmail/api_test.go b/webmail/api_test.go index 2b28f28..63d9c3b 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -227,6 +227,11 @@ func TestAPI(t *testing.T) { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{Sent: true}}) // Sent, for sending mail later. tneedError(t, func() { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: 0}) }) + // MailboxesMarkRead + api.FlagsClear(ctx, []int64{inboxText.ID, inboxMinimal.ID}, []string{`\seen`}) + api.MailboxesMarkRead(ctx, []int64{inbox.ID, archive.ID, sent.ID}) + tneedError(t, func() { api.MailboxesMarkRead(ctx, []int64{inbox.ID + 999}) }) // Does not exist. + // MailboxRename api.MailboxRename(ctx, testbox1.ID, "Testbox2") api.MailboxRename(ctx, testbox1.ID, "Test/A/B/Box1") diff --git a/webmail/msg.js b/webmail/msg.js index b3f520d..bf888ef 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -556,6 +556,15 @@ var api; const params = [messageIDs, flaglist]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are + // not automatically included, they must explicitly be included in the list of IDs. + async MailboxesMarkRead(mailboxIDs) { + const fn = "MailboxesMarkRead"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [mailboxIDs]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // MailboxCreate creates a new mailbox. async MailboxCreate(name) { const fn = "MailboxCreate"; diff --git a/webmail/text.js b/webmail/text.js index 2131cb2..e4d7225 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -556,6 +556,15 @@ var api; const params = [messageIDs, flaglist]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are + // not automatically included, they must explicitly be included in the list of IDs. + async MailboxesMarkRead(mailboxIDs) { + const fn = "MailboxesMarkRead"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [mailboxIDs]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // MailboxCreate creates a new mailbox. async MailboxCreate(name) { const fn = "MailboxCreate"; diff --git a/webmail/webmail.js b/webmail/webmail.js index bd1c0f6..7a0074c 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -556,6 +556,15 @@ var api; const params = [messageIDs, flaglist]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are + // not automatically included, they must explicitly be included in the list of IDs. + async MailboxesMarkRead(mailboxIDs) { + const fn = "MailboxesMarkRead"; + const paramTypes = [["[]", "int64"]]; + const returnTypes = []; + const params = [mailboxIDs]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // MailboxCreate creates a new mailbox. async MailboxCreate(name) { const fn = "MailboxCreate"; @@ -5486,7 +5495,11 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => { let actionBtn; const cmdOpenActions = async () => { const trashmb = mailboxlistView.mailboxes().find(mb => mb.Trash); - const remove = popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Move to trash', attr.title('Move mailbox, its messages and its mailboxes to the trash.'), async function click() { + const remove = popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Mark as read', attr.title('Mark all messages in the mailbox and its sub mailboxes as read.'), async function click() { + remove(); + const mailboxIDs = [mbv.mailbox.ID, ...mailboxlistView.mailboxes().filter(mb => mb.Name.startsWith(mbv.mailbox.Name + '/')).map(mb => mb.ID)]; + await withStatus('Marking mailboxes as read', client.MailboxesMarkRead(mailboxIDs)); + })), dom.div(dom.clickbutton('Move to trash', attr.title('Move mailbox, its messages and its mailboxes to the trash.'), async function click() { if (!trashmb) { window.alert('No mailbox configured for trash yet.'); return; diff --git a/webmail/webmail.ts b/webmail/webmail.ts index ec6ee81..2cb6a20 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -5149,6 +5149,13 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe const remove = popover(actionBtn, {transparent: true}, dom.div(style({display: 'flex', flexDirection: 'column', gap: '.5ex'}), + dom.div( + dom.clickbutton('Mark as read', attr.title('Mark all messages in the mailbox and its sub mailboxes as read.'), async function click() { + remove() + const mailboxIDs = [mbv.mailbox.ID, ...mailboxlistView.mailboxes().filter(mb => mb.Name.startsWith(mbv.mailbox.Name+'/')).map(mb => mb.ID)] + await withStatus('Marking mailboxes as read', client.MailboxesMarkRead(mailboxIDs)) + }), + ), dom.div( dom.clickbutton('Move to trash', attr.title('Move mailbox, its messages and its mailboxes to the trash.'), async function click() { if (!trashmb) { diff --git a/webops/xops.go b/webops/xops.go index 7549c02..6d40864 100644 --- a/webops/xops.go +++ b/webops/xops.go @@ -287,6 +287,62 @@ func (x XOps) MessageFlagsClear(ctx context.Context, log mlog.Log, acc *store.Ac }) } +// MailboxesMarkRead updates all messages in the referenced mailboxes as seen when +// they aren't yet. The mailboxes are updated with their unread messages counts, +// and the changes are propagated. +func (x XOps) MailboxesMarkRead(ctx context.Context, log mlog.Log, acc *store.Account, mailboxIDs []int64) { + acc.WithRLock(func() { + var changes []store.Change + + x.DBWrite(ctx, acc, func(tx *bstore.Tx) { + var modseq store.ModSeq + + // Note: we don't need to retrain, changing the "seen" flag is not relevant. + + for _, mbID := range mailboxIDs { + mb := x.mailboxID(ctx, tx, mbID) + + // Find messages to update. + q := bstore.QueryTx[store.Message](tx) + q.FilterNonzero(store.Message{MailboxID: mb.ID}) + q.FilterEqual("Seen", false) + q.FilterEqual("Expunged", false) + q.SortAsc("UID") + var have bool + err := q.ForEach(func(m store.Message) error { + have = true // We need to update mailbox. + + oflags := m.Flags + mb.Sub(m.MailboxCounts()) + m.Seen = true + mb.Add(m.MailboxCounts()) + + if modseq == 0 { + var err error + modseq, err = acc.NextModSeq(tx) + x.Checkf(ctx, err, "assigning next modseq") + } + m.ModSeq = modseq + err := tx.Update(&m) + x.Checkf(ctx, err, "updating message") + + changes = append(changes, m.ChangeFlags(oflags)) + return nil + }) + x.Checkf(ctx, err, "listing messages to mark as read") + + if have { + err := tx.Update(&mb) + x.Checkf(ctx, err, "updating mailbox") + changes = append(changes, mb.ChangeCounts()) + } + } + }) + + store.BroadcastChanges(acc, changes) + }) +} + // MessageMove moves messages to the mailbox represented by mailboxName, or to mailboxID if mailboxName is empty. func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, mailboxName string, mailboxID int64) { acc.WithRLock(func() {