1// Package webops implements shared functionality between webapisrv and webmail.
13 "golang.org/x/exp/maps"
15 "github.com/mjl-/bstore"
17 "github.com/mjl-/mox/message"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/store"
22var ErrMessageNotFound = errors.New("no such message")
25 DBWrite func(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx))
26 Checkf func(ctx context.Context, err error, format string, args ...any)
27 Checkuserf func(ctx context.Context, err error, format string, args ...any)
30func (x XOps) mailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
32 x.Checkuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
34 mb := store.Mailbox{ID: mailboxID}
36 if err == bstore.ErrAbsent {
37 x.Checkuserf(ctx, err, "getting mailbox")
39 x.Checkf(ctx, err, "getting mailbox")
43// messageID returns a non-expunged message or panics with a sherpa error.
44func (x XOps) messageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
46 x.Checkuserf(ctx, errors.New("invalid zero message id"), "getting message")
48 m := store.Message{ID: messageID}
50 if err == bstore.ErrAbsent {
51 x.Checkuserf(ctx, ErrMessageNotFound, "getting message")
52 } else if err == nil && m.Expunged {
53 x.Checkuserf(ctx, errors.New("message was removed"), "getting message")
55 x.Checkf(ctx, err, "getting message")
59func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64) {
60 acc.WithWLock(func() {
61 var changes []store.Change
63 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
64 _, changes = x.MessageDeleteTx(ctx, log, tx, acc, messageIDs, 0)
67 store.BroadcastChanges(acc, changes)
70 for _, mID := range messageIDs {
71 p := acc.MessagePath(mID)
73 log.Check(err, "removing message file for expunge")
77func (x XOps) MessageDeleteTx(ctx context.Context, log mlog.Log, tx *bstore.Tx, acc *store.Account, messageIDs []int64, modseq store.ModSeq) (store.ModSeq, []store.Change) {
78 removeChanges := map[int64]store.ChangeRemoveUIDs{}
79 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
82 remove := make([]store.Message, 0, len(messageIDs))
85 for _, mid := range messageIDs {
86 m := x.messageID(ctx, tx, mid)
89 if m.MailboxID != mb.ID {
92 x.Checkf(ctx, err, "updating mailbox counts")
93 changes = append(changes, mb.ChangeCounts())
95 mb = x.mailboxID(ctx, tx, m.MailboxID)
98 qmr := bstore.QueryTx[store.Recipient](tx)
99 qmr.FilterEqual("MessageID", m.ID)
100 _, err := qmr.Delete()
101 x.Checkf(ctx, err, "removing message recipients")
103 mb.Sub(m.MailboxCounts())
106 modseq, err = acc.NextModSeq(tx)
107 x.Checkf(ctx, err, "assigning next modseq")
112 x.Checkf(ctx, err, "marking message as expunged")
114 ch := removeChanges[m.MailboxID]
115 ch.UIDs = append(ch.UIDs, m.UID)
116 ch.MailboxID = m.MailboxID
118 removeChanges[m.MailboxID] = ch
119 remove = append(remove, m)
123 err := tx.Update(&mb)
124 x.Checkf(ctx, err, "updating count in mailbox")
125 changes = append(changes, mb.ChangeCounts())
128 err := acc.AddMessageSize(log, tx, -totalSize)
129 x.Checkf(ctx, err, "updating disk usage")
131 // Mark removed messages as not needing training, then retrain them, so if they
132 // were trained, they get untrained.
133 for i := range remove {
134 remove[i].Junk = false
135 remove[i].Notjunk = false
137 err = acc.RetrainMessages(ctx, log, tx, remove, true)
138 x.Checkf(ctx, err, "untraining deleted messages")
140 for _, ch := range removeChanges {
141 sort.Slice(ch.UIDs, func(i, j int) bool {
142 return ch.UIDs[i] < ch.UIDs[j]
144 changes = append(changes, ch)
147 return modseq, changes
150func (x XOps) MessageFlagsAdd(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
151 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
152 x.Checkuserf(ctx, err, "parsing flags")
154 acc.WithRLock(func() {
155 var changes []store.Change
157 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
158 var modseq store.ModSeq
159 var retrain []store.Message
160 var mb, origmb store.Mailbox
162 for _, mid := range messageIDs {
163 m := x.messageID(ctx, tx, mid)
165 if mb.ID != m.MailboxID {
167 err := tx.Update(&mb)
168 x.Checkf(ctx, err, "updating mailbox")
169 if mb.MailboxCounts != origmb.MailboxCounts {
170 changes = append(changes, mb.ChangeCounts())
172 if mb.KeywordsChanged(origmb) {
173 changes = append(changes, mb.ChangeKeywords())
176 mb = x.mailboxID(ctx, tx, m.MailboxID)
179 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
181 mb.Sub(m.MailboxCounts())
183 m.Flags = m.Flags.Set(flags, flags)
185 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
186 mb.Add(m.MailboxCounts())
188 if m.Flags == oflags && !kwChanged {
193 modseq, err = acc.NextModSeq(tx)
194 x.Checkf(ctx, err, "assigning next modseq")
198 x.Checkf(ctx, err, "updating message")
200 changes = append(changes, m.ChangeFlags(oflags))
201 retrain = append(retrain, m)
205 err := tx.Update(&mb)
206 x.Checkf(ctx, err, "updating mailbox")
207 if mb.MailboxCounts != origmb.MailboxCounts {
208 changes = append(changes, mb.ChangeCounts())
210 if mb.KeywordsChanged(origmb) {
211 changes = append(changes, mb.ChangeKeywords())
215 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
216 x.Checkf(ctx, err, "retraining messages")
219 store.BroadcastChanges(acc, changes)
223func (x XOps) MessageFlagsClear(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
224 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
225 x.Checkuserf(ctx, err, "parsing flags")
227 acc.WithRLock(func() {
228 var retrain []store.Message
229 var changes []store.Change
231 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
232 var modseq store.ModSeq
233 var mb, origmb store.Mailbox
235 for _, mid := range messageIDs {
236 m := x.messageID(ctx, tx, mid)
238 if mb.ID != m.MailboxID {
240 err := tx.Update(&mb)
241 x.Checkf(ctx, err, "updating counts for mailbox")
242 if mb.MailboxCounts != origmb.MailboxCounts {
243 changes = append(changes, mb.ChangeCounts())
245 // note: cannot remove keywords from mailbox by removing keywords from message.
247 mb = x.mailboxID(ctx, tx, m.MailboxID)
252 mb.Sub(m.MailboxCounts())
253 m.Flags = m.Flags.Set(flags, store.Flags{})
255 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
256 mb.Add(m.MailboxCounts())
258 if m.Flags == oflags && !changed {
263 modseq, err = acc.NextModSeq(tx)
264 x.Checkf(ctx, err, "assigning next modseq")
268 x.Checkf(ctx, err, "updating message")
270 changes = append(changes, m.ChangeFlags(oflags))
271 retrain = append(retrain, m)
275 err := tx.Update(&mb)
276 x.Checkf(ctx, err, "updating keywords in mailbox")
277 if mb.MailboxCounts != origmb.MailboxCounts {
278 changes = append(changes, mb.ChangeCounts())
280 // note: cannot remove keywords from mailbox by removing keywords from message.
283 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
284 x.Checkf(ctx, err, "retraining messages")
287 store.BroadcastChanges(acc, changes)
291// MailboxesMarkRead updates all messages in the referenced mailboxes as seen when
292// they aren't yet. The mailboxes are updated with their unread messages counts,
293// and the changes are propagated.
294func (x XOps) MailboxesMarkRead(ctx context.Context, log mlog.Log, acc *store.Account, mailboxIDs []int64) {
295 acc.WithRLock(func() {
296 var changes []store.Change
298 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
299 var modseq store.ModSeq
301 // Note: we don't need to retrain, changing the "seen" flag is not relevant.
303 for _, mbID := range mailboxIDs {
304 mb := x.mailboxID(ctx, tx, mbID)
306 // Find messages to update.
307 q := bstore.QueryTx[store.Message](tx)
308 q.FilterNonzero(store.Message{MailboxID: mb.ID})
309 q.FilterEqual("Seen", false)
310 q.FilterEqual("Expunged", false)
313 err := q.ForEach(func(m store.Message) error {
314 have = true // We need to update mailbox.
317 mb.Sub(m.MailboxCounts())
319 mb.Add(m.MailboxCounts())
323 modseq, err = acc.NextModSeq(tx)
324 x.Checkf(ctx, err, "assigning next modseq")
328 x.Checkf(ctx, err, "updating message")
330 changes = append(changes, m.ChangeFlags(oflags))
333 x.Checkf(ctx, err, "listing messages to mark as read")
336 err := tx.Update(&mb)
337 x.Checkf(ctx, err, "updating mailbox")
338 changes = append(changes, mb.ChangeCounts())
343 store.BroadcastChanges(acc, changes)
347// MessageMove moves messages to the mailbox represented by mailboxName, or to mailboxID if mailboxName is empty.
348func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, mailboxName string, mailboxID int64) {
349 acc.WithRLock(func() {
350 var changes []store.Change
352 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
353 if mailboxName != "" {
354 mb, err := acc.MailboxFind(tx, mailboxName)
355 x.Checkf(ctx, err, "looking up mailbox name")
357 x.Checkuserf(ctx, errors.New("not found"), "looking up mailbox name")
363 mbDst := x.mailboxID(ctx, tx, mailboxID)
365 if len(messageIDs) == 0 {
369 _, changes = x.MessageMoveTx(ctx, log, acc, tx, messageIDs, mbDst, 0)
372 store.BroadcastChanges(acc, changes)
376func (x XOps) MessageMoveTx(ctx context.Context, log mlog.Log, acc *store.Account, tx *bstore.Tx, messageIDs []int64, mbDst store.Mailbox, modseq store.ModSeq) (store.ModSeq, []store.Change) {
377 retrain := make([]store.Message, 0, len(messageIDs))
378 removeChanges := map[int64]store.ChangeRemoveUIDs{}
379 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
380 changes := make([]store.Change, 0, len(messageIDs)+3)
382 var mbSrc store.Mailbox
384 keywords := map[string]struct{}{}
387 for _, mid := range messageIDs {
388 m := x.messageID(ctx, tx, mid)
390 // We may have loaded this mailbox in the previous iteration of this loop.
391 if m.MailboxID != mbSrc.ID {
393 err := tx.Update(&mbSrc)
394 x.Checkf(ctx, err, "updating source mailbox counts")
395 changes = append(changes, mbSrc.ChangeCounts())
397 mbSrc = x.mailboxID(ctx, tx, m.MailboxID)
400 if mbSrc.ID == mbDst.ID {
401 // Client should filter out messages that are already in mailbox.
402 x.Checkuserf(ctx, errors.New("already in destination mailbox"), "moving message")
407 modseq, err = acc.NextModSeq(tx)
408 x.Checkf(ctx, err, "assigning next modseq")
411 ch := removeChanges[m.MailboxID]
412 ch.UIDs = append(ch.UIDs, m.UID)
414 ch.MailboxID = m.MailboxID
415 removeChanges[m.MailboxID] = ch
417 // Copy of message record that we'll insert when UID is freed up.
420 om.ID = 0 // Assign new ID.
423 mbSrc.Sub(m.MailboxCounts())
428 conf, _ := acc.Conf()
429 m.MailboxID = mbDst.ID
430 if m.IsReject && m.MailboxDestinedID != 0 {
431 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
432 // is used for reputation calculation during future deliveries.
433 m.MailboxOrigID = m.MailboxDestinedID
437 m.UID = mbDst.UIDNext
440 m.JunkFlagsForMailbox(mbDst, conf)
443 x.Checkf(ctx, err, "updating moved message in database")
445 // Now that UID is unused, we can insert the old record again.
447 x.Checkf(ctx, err, "inserting record for expunge after moving message")
449 mbDst.Add(m.MailboxCounts())
451 changes = append(changes, m.ChangeAddUID())
452 retrain = append(retrain, m)
454 for _, kw := range m.Keywords {
455 keywords[kw] = struct{}{}
459 err := tx.Update(&mbSrc)
460 x.Checkf(ctx, err, "updating source mailbox counts")
462 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
464 // Ensure destination mailbox has keywords of the moved messages.
466 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
468 changes = append(changes, mbDst.ChangeKeywords())
471 err = tx.Update(&mbDst)
472 x.Checkf(ctx, err, "updating mailbox with uidnext")
474 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
475 x.Checkf(ctx, err, "retraining messages after move")
477 // Ensure UIDs of the removed message are in increasing order. It is quite common
478 // for all messages to be from a single source mailbox, meaning this is just one
479 // change, for which we preallocated space.
480 for _, ch := range removeChanges {
481 sort.Slice(ch.UIDs, func(i, j int) bool {
482 return ch.UIDs[i] < ch.UIDs[j]
484 changes = append(changes, ch)
487 return modseq, changes
490func isText(p message.Part) bool {
491 return p.MediaType == "" && p.MediaSubType == "" || p.MediaType == "TEXT" && p.MediaSubType == "PLAIN"
494func isHTML(p message.Part) bool {
495 return p.MediaType == "" && p.MediaSubType == "" || p.MediaType == "TEXT" && p.MediaSubType == "HTML"
498func isAlternative(p message.Part) bool {
499 return p.MediaType == "MULTIPART" && p.MediaSubType == "ALTERNATIVE"
502func readPart(p message.Part, maxSize int64) (string, error) {
503 buf, err := io.ReadAll(io.LimitReader(p.ReaderUTF8OrBinary(), maxSize))
505 return "", fmt.Errorf("reading part contents: %v", err)
507 return string(buf), nil
510// ReadableParts returns the contents of the first text and/or html parts,
511// descending into multiparts, truncated to maxSize bytes if longer.
512func ReadableParts(p message.Part, maxSize int64) (text string, html string, found bool, err error) {
513 // todo: may want to merge this logic with webmail's message parsing.
515 // For non-multipart messages, top-level part.
517 data, err := readPart(p, maxSize)
518 return data, "", true, err
519 } else if isHTML(p) {
520 data, err := readPart(p, maxSize)
521 return "", data, true, err
524 // Look in sub-parts. Stop when we have a readable part, don't continue with other
525 // subparts unless we have a multipart/alternative.
526 // todo: we may have to look at disposition "inline".
527 var haveText, haveHTML bool
528 for _, pp := range p.Parts {
531 text, err = readPart(pp, maxSize)
532 if !isAlternative(p) {
535 } else if isHTML(pp) {
537 html, err = readPart(pp, maxSize)
538 if !isAlternative(p) {
543 if haveText || haveHTML {
544 return text, html, true, err
547 // Descend into the subparts.
548 for _, pp := range p.Parts {
549 text, html, found, err = ReadableParts(pp, maxSize)