1// Package webops implements shared functionality between webapisrv and webmail.
12 "golang.org/x/exp/maps"
14 "github.com/mjl-/bstore"
16 "github.com/mjl-/mox/message"
17 "github.com/mjl-/mox/mlog"
18 "github.com/mjl-/mox/store"
21var ErrMessageNotFound = errors.New("no such message")
24 DBWrite func(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx))
25 Checkf func(ctx context.Context, err error, format string, args ...any)
26 Checkuserf func(ctx context.Context, err error, format string, args ...any)
29func (x XOps) mailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
31 x.Checkuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
33 mb := store.Mailbox{ID: mailboxID}
35 if err == bstore.ErrAbsent {
36 x.Checkuserf(ctx, err, "getting mailbox")
38 x.Checkf(ctx, err, "getting mailbox")
42// messageID returns a non-expunged message or panics with a sherpa error.
43func (x XOps) messageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
45 x.Checkuserf(ctx, errors.New("invalid zero message id"), "getting message")
47 m := store.Message{ID: messageID}
49 if err == bstore.ErrAbsent {
50 x.Checkuserf(ctx, ErrMessageNotFound, "getting message")
51 } else if err == nil && m.Expunged {
52 x.Checkuserf(ctx, errors.New("message was removed"), "getting message")
54 x.Checkf(ctx, err, "getting message")
58func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64) {
59 acc.WithWLock(func() {
60 var changes []store.Change
62 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
63 _, changes = x.MessageDeleteTx(ctx, log, tx, acc, messageIDs, 0)
66 store.BroadcastChanges(acc, changes)
69 for _, mID := range messageIDs {
70 p := acc.MessagePath(mID)
72 log.Check(err, "removing message file for expunge")
76func (x XOps) MessageDeleteTx(ctx context.Context, log mlog.Log, tx *bstore.Tx, acc *store.Account, messageIDs []int64, modseq store.ModSeq) (store.ModSeq, []store.Change) {
77 removeChanges := map[int64]store.ChangeRemoveUIDs{}
78 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
81 remove := make([]store.Message, 0, len(messageIDs))
84 for _, mid := range messageIDs {
85 m := x.messageID(ctx, tx, mid)
88 if m.MailboxID != mb.ID {
91 x.Checkf(ctx, err, "updating mailbox counts")
92 changes = append(changes, mb.ChangeCounts())
94 mb = x.mailboxID(ctx, tx, m.MailboxID)
97 qmr := bstore.QueryTx[store.Recipient](tx)
98 qmr.FilterEqual("MessageID", m.ID)
99 _, err := qmr.Delete()
100 x.Checkf(ctx, err, "removing message recipients")
102 mb.Sub(m.MailboxCounts())
105 modseq, err = acc.NextModSeq(tx)
106 x.Checkf(ctx, err, "assigning next modseq")
111 x.Checkf(ctx, err, "marking message as expunged")
113 ch := removeChanges[m.MailboxID]
114 ch.UIDs = append(ch.UIDs, m.UID)
115 ch.MailboxID = m.MailboxID
117 removeChanges[m.MailboxID] = ch
118 remove = append(remove, m)
122 err := tx.Update(&mb)
123 x.Checkf(ctx, err, "updating count in mailbox")
124 changes = append(changes, mb.ChangeCounts())
127 err := acc.AddMessageSize(log, tx, -totalSize)
128 x.Checkf(ctx, err, "updating disk usage")
130 // Mark removed messages as not needing training, then retrain them, so if they
131 // were trained, they get untrained.
132 for i := range remove {
133 remove[i].Junk = false
134 remove[i].Notjunk = false
136 err = acc.RetrainMessages(ctx, log, tx, remove, true)
137 x.Checkf(ctx, err, "untraining deleted messages")
139 for _, ch := range removeChanges {
140 sort.Slice(ch.UIDs, func(i, j int) bool {
141 return ch.UIDs[i] < ch.UIDs[j]
143 changes = append(changes, ch)
146 return modseq, changes
149func (x XOps) MessageFlagsAdd(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
150 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
151 x.Checkuserf(ctx, err, "parsing flags")
153 acc.WithRLock(func() {
154 var changes []store.Change
156 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
157 var modseq store.ModSeq
158 var retrain []store.Message
159 var mb, origmb store.Mailbox
161 for _, mid := range messageIDs {
162 m := x.messageID(ctx, tx, mid)
164 if mb.ID != m.MailboxID {
166 err := tx.Update(&mb)
167 x.Checkf(ctx, err, "updating mailbox")
168 if mb.MailboxCounts != origmb.MailboxCounts {
169 changes = append(changes, mb.ChangeCounts())
171 if mb.KeywordsChanged(origmb) {
172 changes = append(changes, mb.ChangeKeywords())
175 mb = x.mailboxID(ctx, tx, m.MailboxID)
178 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
180 mb.Sub(m.MailboxCounts())
182 m.Flags = m.Flags.Set(flags, flags)
184 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
185 mb.Add(m.MailboxCounts())
187 if m.Flags == oflags && !kwChanged {
192 modseq, err = acc.NextModSeq(tx)
193 x.Checkf(ctx, err, "assigning next modseq")
197 x.Checkf(ctx, err, "updating message")
199 changes = append(changes, m.ChangeFlags(oflags))
200 retrain = append(retrain, m)
204 err := tx.Update(&mb)
205 x.Checkf(ctx, err, "updating mailbox")
206 if mb.MailboxCounts != origmb.MailboxCounts {
207 changes = append(changes, mb.ChangeCounts())
209 if mb.KeywordsChanged(origmb) {
210 changes = append(changes, mb.ChangeKeywords())
214 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
215 x.Checkf(ctx, err, "retraining messages")
218 store.BroadcastChanges(acc, changes)
222func (x XOps) MessageFlagsClear(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
223 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
224 x.Checkuserf(ctx, err, "parsing flags")
226 acc.WithRLock(func() {
227 var retrain []store.Message
228 var changes []store.Change
230 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
231 var modseq store.ModSeq
232 var mb, origmb store.Mailbox
234 for _, mid := range messageIDs {
235 m := x.messageID(ctx, tx, mid)
237 if mb.ID != m.MailboxID {
239 err := tx.Update(&mb)
240 x.Checkf(ctx, err, "updating counts for mailbox")
241 if mb.MailboxCounts != origmb.MailboxCounts {
242 changes = append(changes, mb.ChangeCounts())
244 // note: cannot remove keywords from mailbox by removing keywords from message.
246 mb = x.mailboxID(ctx, tx, m.MailboxID)
251 mb.Sub(m.MailboxCounts())
252 m.Flags = m.Flags.Set(flags, store.Flags{})
254 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
255 mb.Add(m.MailboxCounts())
257 if m.Flags == oflags && !changed {
262 modseq, err = acc.NextModSeq(tx)
263 x.Checkf(ctx, err, "assigning next modseq")
267 x.Checkf(ctx, err, "updating message")
269 changes = append(changes, m.ChangeFlags(oflags))
270 retrain = append(retrain, m)
274 err := tx.Update(&mb)
275 x.Checkf(ctx, err, "updating keywords in mailbox")
276 if mb.MailboxCounts != origmb.MailboxCounts {
277 changes = append(changes, mb.ChangeCounts())
279 // note: cannot remove keywords from mailbox by removing keywords from message.
282 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
283 x.Checkf(ctx, err, "retraining messages")
286 store.BroadcastChanges(acc, changes)
290// MessageMove moves messages to the mailbox represented by mailboxName, or to mailboxID if mailboxName is empty.
291func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, mailboxName string, mailboxID int64) {
292 acc.WithRLock(func() {
293 var changes []store.Change
295 x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
296 if mailboxName != "" {
297 mb, err := acc.MailboxFind(tx, mailboxName)
298 x.Checkf(ctx, err, "looking up mailbox name")
300 x.Checkuserf(ctx, errors.New("not found"), "looking up mailbox name")
306 mbDst := x.mailboxID(ctx, tx, mailboxID)
308 if len(messageIDs) == 0 {
312 _, changes = x.MessageMoveTx(ctx, log, acc, tx, messageIDs, mbDst, 0)
315 store.BroadcastChanges(acc, changes)
319func (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) {
320 retrain := make([]store.Message, 0, len(messageIDs))
321 removeChanges := map[int64]store.ChangeRemoveUIDs{}
322 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
323 changes := make([]store.Change, 0, len(messageIDs)+3)
325 var mbSrc store.Mailbox
327 keywords := map[string]struct{}{}
329 for _, mid := range messageIDs {
330 m := x.messageID(ctx, tx, mid)
332 // We may have loaded this mailbox in the previous iteration of this loop.
333 if m.MailboxID != mbSrc.ID {
335 err := tx.Update(&mbSrc)
336 x.Checkf(ctx, err, "updating source mailbox counts")
337 changes = append(changes, mbSrc.ChangeCounts())
339 mbSrc = x.mailboxID(ctx, tx, m.MailboxID)
342 if mbSrc.ID == mbDst.ID {
343 // Client should filter out messages that are already in mailbox.
344 x.Checkuserf(ctx, errors.New("already in destination mailbox"), "moving message")
349 modseq, err = acc.NextModSeq(tx)
350 x.Checkf(ctx, err, "assigning next modseq")
353 ch := removeChanges[m.MailboxID]
354 ch.UIDs = append(ch.UIDs, m.UID)
356 ch.MailboxID = m.MailboxID
357 removeChanges[m.MailboxID] = ch
359 // Copy of message record that we'll insert when UID is freed up.
362 om.ID = 0 // Assign new ID.
365 mbSrc.Sub(m.MailboxCounts())
370 conf, _ := acc.Conf()
371 m.MailboxID = mbDst.ID
372 if m.IsReject && m.MailboxDestinedID != 0 {
373 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
374 // is used for reputation calculation during future deliveries.
375 m.MailboxOrigID = m.MailboxDestinedID
379 m.UID = mbDst.UIDNext
382 m.JunkFlagsForMailbox(mbDst, conf)
384 x.Checkf(ctx, err, "updating moved message in database")
386 // Now that UID is unused, we can insert the old record again.
388 x.Checkf(ctx, err, "inserting record for expunge after moving message")
390 mbDst.Add(m.MailboxCounts())
392 changes = append(changes, m.ChangeAddUID())
393 retrain = append(retrain, m)
395 for _, kw := range m.Keywords {
396 keywords[kw] = struct{}{}
400 err := tx.Update(&mbSrc)
401 x.Checkf(ctx, err, "updating source mailbox counts")
403 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
405 // Ensure destination mailbox has keywords of the moved messages.
407 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
409 changes = append(changes, mbDst.ChangeKeywords())
412 err = tx.Update(&mbDst)
413 x.Checkf(ctx, err, "updating mailbox with uidnext")
415 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
416 x.Checkf(ctx, err, "retraining messages after move")
418 // Ensure UIDs of the removed message are in increasing order. It is quite common
419 // for all messages to be from a single source mailbox, meaning this is just one
420 // change, for which we preallocated space.
421 for _, ch := range removeChanges {
422 sort.Slice(ch.UIDs, func(i, j int) bool {
423 return ch.UIDs[i] < ch.UIDs[j]
425 changes = append(changes, ch)
428 return modseq, changes
431func isText(p message.Part) bool {
432 return p.MediaType == "" && p.MediaSubType == "" || p.MediaType == "TEXT" && p.MediaSubType == "PLAIN"
435func isHTML(p message.Part) bool {
436 return p.MediaType == "" && p.MediaSubType == "" || p.MediaType == "TEXT" && p.MediaSubType == "HTML"
439func isAlternative(p message.Part) bool {
440 return p.MediaType == "MULTIPART" && p.MediaSubType == "ALTERNATIVE"
443func readPart(p message.Part, maxSize int64) (string, error) {
444 buf, err := io.ReadAll(io.LimitReader(p.ReaderUTF8OrBinary(), maxSize))
446 return "", fmt.Errorf("reading part contents: %v", err)
448 return string(buf), nil
451// ReadableParts returns the contents of the first text and/or html parts,
452// descending into multiparts, truncated to maxSize bytes if longer.
453func ReadableParts(p message.Part, maxSize int64) (text string, html string, found bool, err error) {
454 // todo: may want to merge this logic with webmail's message parsing.
456 // For non-multipart messages, top-level part.
458 data, err := readPart(p, maxSize)
459 return data, "", true, err
460 } else if isHTML(p) {
461 data, err := readPart(p, maxSize)
462 return "", data, true, err
465 // Look in sub-parts. Stop when we have a readable part, don't continue with other
466 // subparts unless we have a multipart/alternative.
467 // todo: we may have to look at disposition "inline".
468 var haveText, haveHTML bool
469 for _, pp := range p.Parts {
472 text, err = readPart(pp, maxSize)
473 if !isAlternative(p) {
476 } else if isHTML(pp) {
478 html, err = readPart(pp, maxSize)
479 if !isAlternative(p) {
484 if haveText || haveHTML {
485 return text, html, true, err
488 // Descend into the subparts.
489 for _, pp := range p.Parts {
490 text, html, found, err = ReadableParts(pp, maxSize)