13 "mime/quotedprintable"
24 "golang.org/x/exp/maps"
26 "github.com/mjl-/bstore"
27 "github.com/mjl-/sherpa"
28 "github.com/mjl-/sherpadoc"
29 "github.com/mjl-/sherpaprom"
31 "github.com/mjl-/mox/dkim"
32 "github.com/mjl-/mox/dns"
33 "github.com/mjl-/mox/message"
34 "github.com/mjl-/mox/mlog"
35 "github.com/mjl-/mox/mox-"
36 "github.com/mjl-/mox/moxio"
37 "github.com/mjl-/mox/moxvar"
38 "github.com/mjl-/mox/queue"
39 "github.com/mjl-/mox/smtp"
40 "github.com/mjl-/mox/store"
44var webmailapiJSON []byte
47 maxMessageSize int64 // From listener.
50func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
51 err := json.Unmarshal(buf, &doc)
53 xlog.Fatalx("parsing api docs", err, mlog.Field("api", api))
58var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
60var sherpaHandlerOpts *sherpa.HandlerOpts
62func makeSherpaHandler(maxMessageSize int64) (http.Handler, error) {
63 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize}, &webmailDoc, sherpaHandlerOpts)
67 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
69 xlog.Fatalx("creating sherpa prometheus collector", err)
72 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}
74 _, err = makeSherpaHandler(0)
76 xlog.Fatalx("sherpa handler", err)
80// Token returns a token to use for an SSE connection. A token can only be used for
81// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
82// with at most 10 unused tokens (the most recently created) per account.
83func (Webmail) Token(ctx context.Context) string {
84 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
85 return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress)
88// Requests sends a new request for an open SSE connection. Any currently active
89// request for the connection will be canceled, but this is done asynchrously, so
90// the SSE connection may still send results for the previous request. Callers
91// should take care to ignore such results. If req.Cancel is set, no new request is
93func (Webmail) Request(ctx context.Context, req Request) {
94 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
96 if !req.Cancel && req.Page.Count <= 0 {
97 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
100 sse, ok := sseGet(req.SSEID, reqInfo.AccountName)
102 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
107// ParsedMessage returns enough to render the textual body of a message. It is
108// assumed the client already has other fields through MessageItem.
109func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
110 log := xlog.WithContext(ctx)
111 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
112 acc, err := store.OpenAccount(reqInfo.AccountName)
113 xcheckf(ctx, err, "open account")
116 log.Check(err, "closing account")
120 xdbread(ctx, acc, func(tx *bstore.Tx) {
121 m = xmessageID(ctx, tx, msgID)
124 state := msgState{acc: acc}
126 pm, err = parsedMessage(log, m, &state, true, false)
127 xcheckf(ctx, err, "parsing message")
131// Attachment is a MIME part is an existing message that is not intended as
132// viewable text or HTML part.
133type Attachment struct {
134 Path []int // Indices into top-level message.Part.Parts.
136 // File name based on "name" attribute of "Content-Type", or the "filename"
137 // attribute of "Content-Disposition".
138 // todo: decode non-ascii character sets
144// SubmitMessage is an email message to be sent to one or more recipients.
145// Addresses are formatted as just email address, or with a name like "name
147type SubmitMessage struct {
155 ForwardAttachments ForwardAttachments
157 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
158 ReplyTo string // If non-empty, Reply-To header to add to message.
159 UserAgent string // User-Agent header added if not empty.
162// ForwardAttachments references attachments by a list of message.Part paths.
163type ForwardAttachments struct {
164 MessageID int64 // Only relevant if MessageID is not 0.
165 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
168// File is a new attachment (not from an existing message that is being
169// forwarded) to send with a SubmitMessage.
172 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
175// xerrWriter is an io.Writer that panics with a *sherpa.Error when Write
177type xerrWriter struct {
184// Write implements io.Writer, but calls panic (that is handled higher up) on
186func (w *xerrWriter) Write(buf []byte) (int, error) {
187 n, err := w.w.Write(buf)
188 xcheckf(w.ctx, err, "writing message file")
192 xcheckuserf(w.ctx, errors.New("max message size reached"), "writing message file")
198type nameAddress struct {
203// parseAddress expects either a plain email address like "user@domain", or a
204// single address as used in a message header, like "name <user@domain>".
205func parseAddress(msghdr string) (nameAddress, error) {
206 a, err := mail.ParseAddress(msghdr)
208 return nameAddress{}, nil
212 path, err := smtp.ParseAddress(a.Address)
214 return nameAddress{}, err
216 return nameAddress{a.Name, path}, nil
219func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
221 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
223 mb := store.Mailbox{ID: mailboxID}
225 if err == bstore.ErrAbsent {
226 xcheckuserf(ctx, err, "getting mailbox")
228 xcheckf(ctx, err, "getting mailbox")
232// xmessageID returns a non-expunged message or panics with a sherpa error.
233func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
235 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
237 m := store.Message{ID: messageID}
239 if err == bstore.ErrAbsent {
240 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
241 } else if err == nil && m.Expunged {
242 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
244 xcheckf(ctx, err, "getting message")
248// MessageSubmit sends a message by submitting it the outgoing email queue. The
249// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
250// Bcc message header.
252// If a Sent mailbox is configured, messages are added to it after submitting
253// to the delivery queue.
254func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
255 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\(
257 // todo: consider making this an HTTP POST, so we can upload as regular form, which is probably more efficient for encoding for the client and we can stream the data in.
259 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
260 log := xlog.WithContext(ctx).Fields(mlog.Field("account", reqInfo.AccountName))
261 acc, err := store.OpenAccount(reqInfo.AccountName)
262 xcheckf(ctx, err, "open account")
265 log.Check(err, "closing account")
268 log.Debug("message submit")
270 fromAddr, err := parseAddress(m.From)
271 xcheckuserf(ctx, err, "parsing From address")
273 var replyTo *nameAddress
275 a, err := parseAddress(m.ReplyTo)
276 xcheckuserf(ctx, err, "parsing Reply-To address")
280 var recipients []smtp.Address
282 var toAddrs []nameAddress
283 for _, s := range m.To {
284 addr, err := parseAddress(s)
285 xcheckuserf(ctx, err, "parsing To address")
286 toAddrs = append(toAddrs, addr)
287 recipients = append(recipients, addr.Address)
290 var ccAddrs []nameAddress
291 for _, s := range m.Cc {
292 addr, err := parseAddress(s)
293 xcheckuserf(ctx, err, "parsing Cc address")
294 ccAddrs = append(ccAddrs, addr)
295 recipients = append(recipients, addr.Address)
298 for _, s := range m.Bcc {
299 addr, err := parseAddress(s)
300 xcheckuserf(ctx, err, "parsing Bcc address")
301 recipients = append(recipients, addr.Address)
304 // Check if from address is allowed for account.
305 fromAccName, _, _, err := mox.FindAccount(fromAddr.Address.Localpart, fromAddr.Address.Domain, false)
306 if err == nil && fromAccName != reqInfo.AccountName {
307 err = mox.ErrAccountNotFound
309 if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
310 metricSubmission.WithLabelValues("badfrom").Inc()
311 xcheckuserf(ctx, errors.New("address not found"), "looking from address for account")
313 xcheckf(ctx, err, "checking if from address is allowed")
315 if len(recipients) == 0 {
316 xcheckuserf(ctx, fmt.Errorf("no recipients"), "composing message")
319 // Check outgoing message rate limit.
320 xdbread(ctx, acc, func(tx *bstore.Tx) {
321 rcpts := make([]smtp.Path, len(recipients))
322 for i, r := range recipients {
323 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
325 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
327 metricSubmission.WithLabelValues("messagelimiterror").Inc()
328 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
329 } else if rcptlimit >= 0 {
330 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
331 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
333 xcheckf(ctx, err, "checking send limit")
336 has8bit := false // We update this later on.
338 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
340 for _, a := range recipients {
341 if a.Localpart.IsInternational() {
346 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
347 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
351 // Create file to compose message into.
352 dataFile, err := store.CreateMessageTemp("webmail-submit")
353 xcheckf(ctx, err, "creating temporary file for message")
356 err := dataFile.Close()
357 log.Check(err, "closing submit message file")
358 err = os.Remove(dataFile.Name())
359 log.Check(err, "removing temporary submit message file")
363 // If writing to the message file fails, we abort immediately.
364 xmsgw := &xerrWriter{ctx, bufio.NewWriter(dataFile), 0, w.maxMessageSize}
366 isASCII := func(s string) bool {
367 for _, c := range s {
375 header := func(k, v string) {
376 fmt.Fprintf(xmsgw, "%s: %s\r\n", k, v)
379 headerAddrs := func(k string, l []nameAddress) {
384 linelen := len(k) + len(": ")
385 for _, a := range l {
390 addr := mail.Address{Name: a.Name, Address: a.Address.Pack(smtputf8)}
392 if v != "" && linelen+1+len(s) > 77 {
402 fmt.Fprintf(xmsgw, "%s: %s\r\n", k, v)
405 line := func(w io.Writer) {
406 _, _ = w.Write([]byte("\r\n"))
410 if !strings.HasSuffix(text, "\n") {
413 text = strings.ReplaceAll(text, "\n", "\r\n")
415 charset := "us-ascii"
421 if message.NeedsQuotedPrintable(text) {
422 var sb strings.Builder
423 _, err := io.Copy(quotedprintable.NewWriter(&sb), strings.NewReader(text))
424 xcheckf(ctx, err, "converting text to quoted printable")
426 cte = "quoted-printable"
427 } else if has8bit || charset == "utf-8" {
433 // todo spec: can we add an Authentication-Results header that indicates this is an authenticated message? the "auth" method is for SMTP AUTH, which this isn't.
../rfc/8601 https://www.iana.org/assignments/email-auth/email-auth.xhtml
435 // Each queued message gets a Received header.
436 // We don't have access to the local IP for adding.
437 // We cannot use VIA, because there is no registered method. We would like to use
438 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
439 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
440 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
441 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
442 recvHdrFor := func(rcptTo string) string {
443 recvHdr := &message.HeaderWriter{}
444 // For additional Received-header clauses, see:
445 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
446 // Note: we don't have "via" or "with", there is no registered for webmail.
447 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
448 if reqInfo.Request.TLS != nil {
449 recvHdr.Add(" ", message.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
451 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
452 return recvHdr.String()
455 // Outer message headers.
456 headerAddrs("From", []nameAddress{fromAddr})
458 headerAddrs("Reply-To", []nameAddress{*replyTo})
460 headerAddrs("To", toAddrs)
461 headerAddrs("Cc", ccAddrs)
463 var subjectValue string
464 subjectLineLen := len("Subject: ")
466 for i, word := range strings.Split(m.Subject, " ") {
467 if !smtputf8 && !isASCII(word) {
468 word = mime.QEncoding.Encode("utf-8", word)
474 if subjectWord && subjectLineLen+len(word) > 77 {
475 subjectValue += "\r\n\t"
479 subjectLineLen += len(word)
482 if subjectValue != "" {
483 header("Subject", subjectValue)
486 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
487 header("Message-Id", messageID)
488 header("Date", time.Now().Format(message.RFC5322Z))
489 // Add In-Reply-To and References headers.
490 if m.ResponseMessageID > 0 {
491 xdbread(ctx, acc, func(tx *bstore.Tx) {
492 rm := xmessageID(ctx, tx, m.ResponseMessageID)
493 msgr := acc.MessageReader(rm)
496 log.Check(err, "closing message reader")
498 rp, err := rm.LoadPart(msgr)
499 xcheckf(ctx, err, "load parsed message")
500 h, err := rp.Header()
501 xcheckf(ctx, err, "parsing header")
503 if rp.Envelope == nil {
506 header("In-Reply-To", rp.Envelope.MessageID)
507 ref := h.Get("References")
509 ref = h.Get("In-Reply-To")
512 header("References", ref+"\r\n\t"+rp.Envelope.MessageID)
514 header("References", rp.Envelope.MessageID)
518 if m.UserAgent != "" {
519 header("User-Agent", m.UserAgent)
521 header("MIME-Version", "1.0")
523 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
524 mp := multipart.NewWriter(xmsgw)
525 header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
528 textHdr := textproto.MIMEHeader{}
529 textHdr.Set("Content-Type", "text/plain; charset="+escapeParam(charset))
530 textHdr.Set("Content-Transfer-Encoding", cte)
531 textp, err := mp.CreatePart(textHdr)
532 xcheckf(ctx, err, "adding text part to message")
533 _, err = textp.Write([]byte(text))
534 xcheckf(ctx, err, "writing text part")
536 xaddPart := func(ct, filename string) io.Writer {
537 ahdr := textproto.MIMEHeader{}
539 ct = "application/octet-stream"
541 ct += fmt.Sprintf(`; name="%s"`, filename)
542 ahdr.Set("Content-Type", ct)
543 ahdr.Set("Content-Transfer-Encoding", "base64")
544 ahdr.Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%s`, escapeParam(filename)))
545 ap, err := mp.CreatePart(ahdr)
546 xcheckf(ctx, err, "adding attachment part to message")
550 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
551 ap := xaddPart(ct, filename)
553 for len(base64Data) > 0 {
559 line, base64Data = base64Data[:n], base64Data[n:]
560 _, err := ap.Write(line)
561 xcheckf(ctx, err, "writing attachment")
562 _, err = ap.Write([]byte("\r\n"))
563 xcheckf(ctx, err, "writing attachment")
567 xaddAttachment := func(ct, filename string, r io.Reader) {
568 ap := xaddPart(ct, filename)
569 wc := moxio.Base64Writer(ap)
570 _, err := io.Copy(wc, r)
571 xcheckf(ctx, err, "adding attachment")
573 xcheckf(ctx, err, "flushing attachment")
576 for _, a := range m.Attachments {
578 if !strings.HasPrefix(s, "data:") {
579 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
582 t := strings.SplitN(s, ",", 2)
584 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
586 if !strings.HasSuffix(t[0], "base64") {
587 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
589 ct := strings.TrimSuffix(t[0], "base64")
590 ct = strings.TrimSuffix(ct, ";")
592 // Ensure base64 is valid, then we'll write the original string.
593 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
594 xcheckuserf(ctx, err, "parsing attachment as base64")
596 xaddAttachmentBase64(ct, a.Filename, []byte(t[1]))
599 if len(m.ForwardAttachments.Paths) > 0 {
600 acc.WithRLock(func() {
601 xdbread(ctx, acc, func(tx *bstore.Tx) {
602 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
603 msgr := acc.MessageReader(fm)
606 log.Check(err, "closing message reader")
609 fp, err := fm.LoadPart(msgr)
610 xcheckf(ctx, err, "load parsed message")
612 for _, path := range m.ForwardAttachments.Paths {
614 for _, xp := range path {
615 if xp < 0 || xp >= len(ap.Parts) {
616 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
621 filename := ap.ContentTypeParams["name"]
623 filename = "unnamed.bin"
625 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
626 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
627 ct += "; charset=" + escapeParam(pcharset)
629 xaddAttachment(ct, filename, ap.Reader())
636 xcheckf(ctx, err, "writing mime multipart")
638 header("Content-Type", "text/plain; charset="+escapeParam(charset))
639 header("Content-Transfer-Encoding", cte)
641 xmsgw.Write([]byte(text))
644 err = xmsgw.w.Flush()
645 xcheckf(ctx, err, "writing message")
647 // Add DKIM-Signature headers.
649 fd := fromAddr.Address.Domain
650 confDom, _ := mox.Conf.Domain(fd)
651 if len(confDom.DKIM.Sign) > 0 {
652 dkimHeaders, err := dkim.Sign(ctx, fromAddr.Address.Localpart, fd, confDom.DKIM, smtputf8, dataFile)
654 metricServerErrors.WithLabelValues("dkimsign").Inc()
656 xcheckf(ctx, err, "sign dkim")
658 msgPrefix = dkimHeaders
661 fromPath := smtp.Path{
662 Localpart: fromAddr.Address.Localpart,
663 IPDomain: dns.IPDomain{Domain: fromAddr.Address.Domain},
665 for _, rcpt := range recipients {
666 rcptMsgPrefix := recvHdrFor(rcpt.Pack(smtputf8)) + msgPrefix
667 msgSize := int64(len(rcptMsgPrefix)) + xmsgw.size
669 Localpart: rcpt.Localpart,
670 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
672 _, err := queue.Add(ctx, log, reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), dataFile, nil, false)
674 metricSubmission.WithLabelValues("queueerror").Inc()
676 xcheckf(ctx, err, "adding message to the delivery queue")
677 metricSubmission.WithLabelValues("ok").Inc()
680 var modseq store.ModSeq // Only set if needed.
682 // Append message to Sent mailbox and mark original messages as answered/forwarded.
683 acc.WithRLock(func() {
684 var changes []store.Change
688 if x := recover(); x != nil {
690 metricServerErrors.WithLabelValues("submit").Inc()
695 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
696 if m.ResponseMessageID > 0 {
697 rm := xmessageID(ctx, tx, m.ResponseMessageID)
704 if !rm.Junk && !rm.Notjunk {
707 if rm.Flags != oflags {
708 modseq, err = acc.NextModSeq(tx)
709 xcheckf(ctx, err, "next modseq")
711 err := tx.Update(&rm)
712 xcheckf(ctx, err, "updating flags of replied/forwarded message")
713 changes = append(changes, rm.ChangeFlags(oflags))
715 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
716 xcheckf(ctx, err, "retraining messages after reply/forward")
720 sentmb, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Sent", true).Get()
721 if err == bstore.ErrAbsent {
722 // There is no mailbox designated as Sent mailbox, so we're done.
723 err := os.Remove(dataFile.Name())
724 log.Check(err, "removing submitmessage file")
725 err = dataFile.Close()
726 log.Check(err, "closing submitmessage file")
730 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
733 modseq, err = acc.NextModSeq(tx)
734 xcheckf(ctx, err, "next modseq")
737 sentm := store.Message{
740 MailboxID: sentmb.ID,
741 MailboxOrigID: sentmb.ID,
742 Flags: store.Flags{Notjunk: true, Seen: true},
743 Size: int64(len(msgPrefix)) + xmsgw.size,
744 MsgPrefix: []byte(msgPrefix),
747 // Update mailbox before delivery, which changes uidnext.
748 sentmb.Add(sentm.MailboxCounts())
749 err = tx.Update(&sentmb)
750 xcheckf(ctx, err, "updating sent mailbox for counts")
752 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, true, false, false)
754 metricSubmission.WithLabelValues("storesenterror").Inc()
757 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
759 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
761 err = dataFile.Close()
762 log.Check(err, "closing submit message file")
766 store.BroadcastChanges(acc, changes)
770// MessageMove moves messages to another mailbox. If the message is already in
771// the mailbox an error is returned.
772func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
773 log := xlog.WithContext(ctx)
774 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
775 acc, err := store.OpenAccount(reqInfo.AccountName)
776 xcheckf(ctx, err, "open account")
779 log.Check(err, "closing account")
782 acc.WithRLock(func() {
783 retrain := make([]store.Message, 0, len(messageIDs))
784 removeChanges := map[int64]store.ChangeRemoveUIDs{}
785 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
786 changes := make([]store.Change, 0, len(messageIDs)+3)
788 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
789 var mbSrc store.Mailbox
790 var modseq store.ModSeq
792 mbDst := xmailboxID(ctx, tx, mailboxID)
794 if len(messageIDs) == 0 {
798 keywords := map[string]struct{}{}
800 for _, mid := range messageIDs {
801 m := xmessageID(ctx, tx, mid)
803 // We may have loaded this mailbox in the previous iteration of this loop.
804 if m.MailboxID != mbSrc.ID {
806 err = tx.Update(&mbSrc)
807 xcheckf(ctx, err, "updating source mailbox counts")
808 changes = append(changes, mbSrc.ChangeCounts())
810 mbSrc = xmailboxID(ctx, tx, m.MailboxID)
813 if mbSrc.ID == mailboxID {
814 // Client should filter out messages that are already in mailbox.
815 xcheckuserf(ctx, errors.New("already in destination mailbox"), "moving message")
819 modseq, err = acc.NextModSeq(tx)
820 xcheckf(ctx, err, "assigning next modseq")
823 ch := removeChanges[m.MailboxID]
824 ch.UIDs = append(ch.UIDs, m.UID)
826 ch.MailboxID = m.MailboxID
827 removeChanges[m.MailboxID] = ch
829 // Copy of message record that we'll insert when UID is freed up.
832 om.ID = 0 // Assign new ID.
835 mbSrc.Sub(m.MailboxCounts())
840 conf, _ := acc.Conf()
841 m.MailboxID = mbDst.ID
842 if m.IsReject && m.MailboxDestinedID != 0 {
843 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
844 // is used for reputation calculation during future deliveries.
845 m.MailboxOrigID = m.MailboxDestinedID
849 m.UID = mbDst.UIDNext
852 m.JunkFlagsForMailbox(mbDst, conf)
854 xcheckf(ctx, err, "updating moved message in database")
856 // Now that UID is unused, we can insert the old record again.
858 xcheckf(ctx, err, "inserting record for expunge after moving message")
860 mbDst.Add(m.MailboxCounts())
862 changes = append(changes, m.ChangeAddUID())
863 retrain = append(retrain, m)
865 for _, kw := range m.Keywords {
866 keywords[kw] = struct{}{}
870 err = tx.Update(&mbSrc)
871 xcheckf(ctx, err, "updating source mailbox counts")
873 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
875 // Ensure destination mailbox has keywords of the moved messages.
877 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
879 changes = append(changes, mbDst.ChangeKeywords())
882 err = tx.Update(&mbDst)
883 xcheckf(ctx, err, "updating mailbox with uidnext")
885 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
886 xcheckf(ctx, err, "retraining messages after move")
889 // Ensure UIDs of the removed message are in increasing order. It is quite common
890 // for all messages to be from a single source mailbox, meaning this is just one
891 // change, for which we preallocated space.
892 for _, ch := range removeChanges {
893 sort.Slice(ch.UIDs, func(i, j int) bool {
894 return ch.UIDs[i] < ch.UIDs[j]
896 changes = append(changes, ch)
898 store.BroadcastChanges(acc, changes)
902// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
903func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
904 log := xlog.WithContext(ctx)
905 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
906 acc, err := store.OpenAccount(reqInfo.AccountName)
907 xcheckf(ctx, err, "open account")
910 log.Check(err, "closing account")
913 if len(messageIDs) == 0 {
917 acc.WithWLock(func() {
918 removeChanges := map[int64]store.ChangeRemoveUIDs{}
919 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
921 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
922 var modseq store.ModSeq
924 remove := make([]store.Message, 0, len(messageIDs))
926 for _, mid := range messageIDs {
927 m := xmessageID(ctx, tx, mid)
929 if m.MailboxID != mb.ID {
931 err := tx.Update(&mb)
932 xcheckf(ctx, err, "updating mailbox counts")
933 changes = append(changes, mb.ChangeCounts())
935 mb = xmailboxID(ctx, tx, m.MailboxID)
938 qmr := bstore.QueryTx[store.Recipient](tx)
939 qmr.FilterEqual("MessageID", m.ID)
940 _, err = qmr.Delete()
941 xcheckf(ctx, err, "removing message recipients")
943 mb.Sub(m.MailboxCounts())
946 modseq, err = acc.NextModSeq(tx)
947 xcheckf(ctx, err, "assigning next modseq")
952 xcheckf(ctx, err, "marking message as expunged")
954 ch := removeChanges[m.MailboxID]
955 ch.UIDs = append(ch.UIDs, m.UID)
956 ch.MailboxID = m.MailboxID
958 removeChanges[m.MailboxID] = ch
959 remove = append(remove, m)
963 err := tx.Update(&mb)
964 xcheckf(ctx, err, "updating count in mailbox")
965 changes = append(changes, mb.ChangeCounts())
968 // Mark removed messages as not needing training, then retrain them, so if they
969 // were trained, they get untrained.
970 for i := range remove {
971 remove[i].Junk = false
972 remove[i].Notjunk = false
974 err = acc.RetrainMessages(ctx, log, tx, remove, true)
975 xcheckf(ctx, err, "untraining deleted messages")
978 for _, ch := range removeChanges {
979 sort.Slice(ch.UIDs, func(i, j int) bool {
980 return ch.UIDs[i] < ch.UIDs[j]
982 changes = append(changes, ch)
984 store.BroadcastChanges(acc, changes)
987 for _, mID := range messageIDs {
988 p := acc.MessagePath(mID)
990 log.Check(err, "removing message file for expunge")
994// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
995// flags should be lower-case, but will be converted and verified.
996func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
997 log := xlog.WithContext(ctx)
998 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
999 acc, err := store.OpenAccount(reqInfo.AccountName)
1000 xcheckf(ctx, err, "open account")
1003 log.Check(err, "closing account")
1006 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
1007 xcheckuserf(ctx, err, "parsing flags")
1009 acc.WithRLock(func() {
1010 var changes []store.Change
1012 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1013 var modseq store.ModSeq
1014 var retrain []store.Message
1015 var mb, origmb store.Mailbox
1017 for _, mid := range messageIDs {
1018 m := xmessageID(ctx, tx, mid)
1020 if mb.ID != m.MailboxID {
1022 err := tx.Update(&mb)
1023 xcheckf(ctx, err, "updating mailbox")
1024 if mb.MailboxCounts != origmb.MailboxCounts {
1025 changes = append(changes, mb.ChangeCounts())
1027 if mb.KeywordsChanged(origmb) {
1028 changes = append(changes, mb.ChangeKeywords())
1031 mb = xmailboxID(ctx, tx, m.MailboxID)
1034 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
1036 mb.Sub(m.MailboxCounts())
1038 m.Flags = m.Flags.Set(flags, flags)
1040 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
1041 mb.Add(m.MailboxCounts())
1043 if m.Flags == oflags && !kwChanged {
1048 modseq, err = acc.NextModSeq(tx)
1049 xcheckf(ctx, err, "assigning next modseq")
1053 xcheckf(ctx, err, "updating message")
1055 changes = append(changes, m.ChangeFlags(oflags))
1056 retrain = append(retrain, m)
1060 err := tx.Update(&mb)
1061 xcheckf(ctx, err, "updating mailbox")
1062 if mb.MailboxCounts != origmb.MailboxCounts {
1063 changes = append(changes, mb.ChangeCounts())
1065 if mb.KeywordsChanged(origmb) {
1066 changes = append(changes, mb.ChangeKeywords())
1070 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1071 xcheckf(ctx, err, "retraining messages")
1074 store.BroadcastChanges(acc, changes)
1078// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1079func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1080 log := xlog.WithContext(ctx)
1081 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1082 acc, err := store.OpenAccount(reqInfo.AccountName)
1083 xcheckf(ctx, err, "open account")
1086 log.Check(err, "closing account")
1089 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
1090 xcheckuserf(ctx, err, "parsing flags")
1092 acc.WithRLock(func() {
1093 var retrain []store.Message
1094 var changes []store.Change
1096 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1097 var modseq store.ModSeq
1098 var mb, origmb store.Mailbox
1100 for _, mid := range messageIDs {
1101 m := xmessageID(ctx, tx, mid)
1103 if mb.ID != m.MailboxID {
1105 err := tx.Update(&mb)
1106 xcheckf(ctx, err, "updating counts for mailbox")
1107 if mb.MailboxCounts != origmb.MailboxCounts {
1108 changes = append(changes, mb.ChangeCounts())
1110 // note: cannot remove keywords from mailbox by removing keywords from message.
1112 mb = xmailboxID(ctx, tx, m.MailboxID)
1117 mb.Sub(m.MailboxCounts())
1118 m.Flags = m.Flags.Set(flags, store.Flags{})
1120 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
1121 mb.Add(m.MailboxCounts())
1123 if m.Flags == oflags && !changed {
1128 modseq, err = acc.NextModSeq(tx)
1129 xcheckf(ctx, err, "assigning next modseq")
1133 xcheckf(ctx, err, "updating message")
1135 changes = append(changes, m.ChangeFlags(oflags))
1136 retrain = append(retrain, m)
1140 err := tx.Update(&mb)
1141 xcheckf(ctx, err, "updating keywords in mailbox")
1142 if mb.MailboxCounts != origmb.MailboxCounts {
1143 changes = append(changes, mb.ChangeCounts())
1145 // note: cannot remove keywords from mailbox by removing keywords from message.
1148 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1149 xcheckf(ctx, err, "retraining messages")
1152 store.BroadcastChanges(acc, changes)
1156// MailboxCreate creates a new mailbox.
1157func (Webmail) MailboxCreate(ctx context.Context, name string) {
1158 log := xlog.WithContext(ctx)
1159 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1160 acc, err := store.OpenAccount(reqInfo.AccountName)
1161 xcheckf(ctx, err, "open account")
1164 log.Check(err, "closing account")
1167 name, _, err = store.CheckMailboxName(name, false)
1168 xcheckuserf(ctx, err, "checking mailbox name")
1170 acc.WithWLock(func() {
1171 var changes []store.Change
1172 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1175 changes, _, exists, err = acc.MailboxCreate(tx, name)
1177 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1179 xcheckf(ctx, err, "creating mailbox")
1182 store.BroadcastChanges(acc, changes)
1186// MailboxDelete deletes a mailbox and all its messages.
1187func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1188 log := xlog.WithContext(ctx)
1189 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1190 acc, err := store.OpenAccount(reqInfo.AccountName)
1191 xcheckf(ctx, err, "open account")
1194 log.Check(err, "closing account")
1197 // Messages to remove after having broadcasted the removal of messages.
1198 var removeMessageIDs []int64
1200 acc.WithWLock(func() {
1201 var changes []store.Change
1203 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1204 mb := xmailboxID(ctx, tx, mailboxID)
1205 if mb.Name == "Inbox" {
1206 // Inbox is special in IMAP and cannot be removed.
1207 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1210 var hasChildren bool
1212 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1214 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1216 xcheckf(ctx, err, "deleting mailbox")
1219 store.BroadcastChanges(acc, changes)
1222 for _, mID := range removeMessageIDs {
1223 p := acc.MessagePath(mID)
1225 log.Check(err, "removing message file for mailbox delete", mlog.Field("path", p))
1229// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1230// its child mailboxes.
1231func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1232 log := xlog.WithContext(ctx)
1233 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1234 acc, err := store.OpenAccount(reqInfo.AccountName)
1235 xcheckf(ctx, err, "open account")
1238 log.Check(err, "closing account")
1241 var expunged []store.Message
1243 acc.WithWLock(func() {
1244 var changes []store.Change
1246 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1247 mb := xmailboxID(ctx, tx, mailboxID)
1249 modseq, err := acc.NextModSeq(tx)
1250 xcheckf(ctx, err, "next modseq")
1252 // Mark messages as expunged.
1253 qm := bstore.QueryTx[store.Message](tx)
1254 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1255 qm.FilterEqual("Expunged", false)
1257 qm.Gather(&expunged)
1258 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1259 xcheckf(ctx, err, "deleting messages")
1261 // Remove Recipients.
1262 anyIDs := make([]any, len(expunged))
1263 for i, m := range expunged {
1266 qmr := bstore.QueryTx[store.Recipient](tx)
1267 qmr.FilterEqual("MessageID", anyIDs...)
1268 _, err = qmr.Delete()
1269 xcheckf(ctx, err, "removing message recipients")
1271 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1272 uids := make([]store.UID, len(expunged))
1273 for i, m := range expunged {
1274 m.Expunged = false // Gather returns updated values.
1275 mb.Sub(m.MailboxCounts())
1278 expunged[i].Junk = false
1279 expunged[i].Notjunk = false
1282 err = tx.Update(&mb)
1283 xcheckf(ctx, err, "updating mailbox for counts")
1285 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1286 xcheckf(ctx, err, "retraining expunged messages")
1288 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1289 changes = []store.Change{chremove, mb.ChangeCounts()}
1292 store.BroadcastChanges(acc, changes)
1295 for _, m := range expunged {
1296 p := acc.MessagePath(m.ID)
1298 log.Check(err, "removing message file after emptying mailbox", mlog.Field("path", p))
1302// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1303// ID and its messages are unchanged.
1304func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1305 log := xlog.WithContext(ctx)
1306 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1307 acc, err := store.OpenAccount(reqInfo.AccountName)
1308 xcheckf(ctx, err, "open account")
1311 log.Check(err, "closing account")
1314 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1315 // standard. We can just say no.
1316 newName, _, err = store.CheckMailboxName(newName, false)
1317 xcheckuserf(ctx, err, "checking new mailbox name")
1319 acc.WithWLock(func() {
1320 var changes []store.Change
1322 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1323 mbsrc := xmailboxID(ctx, tx, mailboxID)
1325 var isInbox, notExists, alreadyExists bool
1326 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1327 if isInbox || notExists || alreadyExists {
1328 xcheckuserf(ctx, err, "renaming mailbox")
1330 xcheckf(ctx, err, "renaming mailbox")
1333 store.BroadcastChanges(acc, changes)
1337// CompleteRecipient returns autocomplete matches for a recipient, returning the
1338// matches, most recently used first, and whether this is the full list and further
1339// requests for longer prefixes aren't necessary.
1340func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1341 log := xlog.WithContext(ctx)
1342 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1343 acc, err := store.OpenAccount(reqInfo.AccountName)
1344 xcheckf(ctx, err, "open account")
1347 log.Check(err, "closing account")
1350 search = strings.ToLower(search)
1352 var matches []string
1354 acc.WithRLock(func() {
1355 xdbread(ctx, acc, func(tx *bstore.Tx) {
1357 localpart smtp.Localpart
1360 seen := map[key]bool{}
1362 q := bstore.QueryTx[store.Recipient](tx)
1364 err := q.ForEach(func(r store.Recipient) error {
1365 k := key{r.Localpart, r.Domain}
1369 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1370 address := fmt.Sprintf("<%s@%s>", r.Localpart.String(), r.Domain)
1371 if !strings.Contains(strings.ToLower(address), search) {
1374 if len(matches) >= 20 {
1376 return bstore.StopForEach
1379 // Look in the message that was sent for a name along with the address.
1380 m := store.Message{ID: r.MessageID}
1382 xcheckf(ctx, err, "get sent message")
1383 if !m.Expunged && m.ParsedBuf != nil {
1384 var part message.Part
1385 err := json.Unmarshal(m.ParsedBuf, &part)
1386 xcheckf(ctx, err, "parsing part")
1388 dom, err := dns.ParseDomain(r.Domain)
1389 xcheckf(ctx, err, "parsing domain of recipient")
1392 lp := r.Localpart.String()
1393 checkAddrs := func(l []message.Address) {
1397 for _, a := range l {
1398 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1400 address = addressString(a, false)
1405 if part.Envelope != nil {
1406 env := part.Envelope
1413 matches = append(matches, address)
1417 xcheckf(ctx, err, "listing recipients")
1423// addressString returns an address into a string as it could be used in a message header.
1424func addressString(a message.Address, smtputf8 bool) string {
1426 dom, err := dns.ParseDomain(a.Host)
1428 if smtputf8 && dom.Unicode != "" {
1434 s := "<" + a.User + "@" + host + ">"
1436 // todo: properly encoded/escaped name
1437 s = a.Name + " " + s
1442// MailboxSetSpecialUse sets the special use flags of a mailbox.
1443func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1444 log := xlog.WithContext(ctx)
1445 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1446 acc, err := store.OpenAccount(reqInfo.AccountName)
1447 xcheckf(ctx, err, "open account")
1450 log.Check(err, "closing account")
1453 acc.WithWLock(func() {
1454 var changes []store.Change
1456 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1457 xmb := xmailboxID(ctx, tx, mb.ID)
1459 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1460 // we set, we clear it for the mailbox(es) that had it, if any.
1461 clearPrevious := func(clear bool, specialUse string) {
1465 var ombl []store.Mailbox
1466 q := bstore.QueryTx[store.Mailbox](tx)
1467 q.FilterNotEqual("ID", mb.ID)
1468 q.FilterEqual(specialUse, true)
1470 _, err := q.UpdateField(specialUse, false)
1471 xcheckf(ctx, err, "updating previous special-use mailboxes")
1473 for _, omb := range ombl {
1474 changes = append(changes, omb.ChangeSpecialUse())
1477 clearPrevious(mb.Archive, "Archive")
1478 clearPrevious(mb.Draft, "Draft")
1479 clearPrevious(mb.Junk, "Junk")
1480 clearPrevious(mb.Sent, "Sent")
1481 clearPrevious(mb.Trash, "Trash")
1483 xmb.SpecialUse = mb.SpecialUse
1484 err = tx.Update(&xmb)
1485 xcheckf(ctx, err, "updating special-use flags for mailbox")
1486 changes = append(changes, xmb.ChangeSpecialUse())
1489 store.BroadcastChanges(acc, changes)
1493// ThreadCollapse saves the ThreadCollapse field for the messages and its
1494// children. The messageIDs are typically thread roots. But not all roots
1495// (without parent) of a thread need to have the same collapsed state.
1496func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1497 log := xlog.WithContext(ctx)
1498 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1499 acc, err := store.OpenAccount(reqInfo.AccountName)
1500 xcheckf(ctx, err, "open account")
1503 log.Check(err, "closing account")
1506 if len(messageIDs) == 0 {
1507 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1510 acc.WithWLock(func() {
1511 changes := make([]store.Change, 0, len(messageIDs))
1512 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1513 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1514 // (child) messages. Further refined in FilterFn.
1515 threadIDs := map[int64]struct{}{}
1516 msgIDs := map[int64]struct{}{}
1517 for _, id := range messageIDs {
1518 m := store.Message{ID: id}
1520 if err == bstore.ErrAbsent {
1521 xcheckuserf(ctx, err, "get message")
1523 xcheckf(ctx, err, "get message")
1524 threadIDs[m.ThreadID] = struct{}{}
1525 msgIDs[id] = struct{}{}
1528 var updated []store.Message
1529 q := bstore.QueryTx[store.Message](tx)
1530 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1531 q.FilterNotEqual("ThreadCollapsed", collapse)
1532 q.FilterFn(func(tm store.Message) bool {
1533 for _, id := range tm.ThreadParentIDs {
1534 if _, ok := msgIDs[id]; ok {
1538 _, ok := msgIDs[tm.ID]
1542 q.SortAsc("ID") // Consistent order for testing.
1543 _, err = q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1544 xcheckf(ctx, err, "updating collapse in database")
1546 for _, m := range updated {
1547 changes = append(changes, m.ChangeThread())
1550 store.BroadcastChanges(acc, changes)
1554// ThreadMute saves the ThreadMute field for the messages and their children.
1555// If messages are muted, they are also marked collapsed.
1556func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1557 log := xlog.WithContext(ctx)
1558 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1559 acc, err := store.OpenAccount(reqInfo.AccountName)
1560 xcheckf(ctx, err, "open account")
1563 log.Check(err, "closing account")
1566 if len(messageIDs) == 0 {
1567 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1570 acc.WithWLock(func() {
1571 changes := make([]store.Change, 0, len(messageIDs))
1572 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1573 threadIDs := map[int64]struct{}{}
1574 msgIDs := map[int64]struct{}{}
1575 for _, id := range messageIDs {
1576 m := store.Message{ID: id}
1578 if err == bstore.ErrAbsent {
1579 xcheckuserf(ctx, err, "get message")
1581 xcheckf(ctx, err, "get message")
1582 threadIDs[m.ThreadID] = struct{}{}
1583 msgIDs[id] = struct{}{}
1586 var updated []store.Message
1588 q := bstore.QueryTx[store.Message](tx)
1589 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1590 q.FilterFn(func(tm store.Message) bool {
1591 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1594 for _, id := range tm.ThreadParentIDs {
1595 if _, ok := msgIDs[id]; ok {
1599 _, ok := msgIDs[tm.ID]
1603 fields := map[string]any{"ThreadMuted": mute}
1605 fields["ThreadCollapsed"] = true
1607 _, err = q.UpdateFields(fields)
1608 xcheckf(ctx, err, "updating mute in database")
1610 for _, m := range updated {
1611 changes = append(changes, m.ChangeThread())
1614 store.BroadcastChanges(acc, changes)
1618func slicesAny[T any](l []T) []any {
1619 r := make([]any, len(l))
1620 for i, v := range l {
1626// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
1627func (Webmail) SSETypes() (start EventStart, viewErr EventViewErr, viewReset EventViewReset, viewMsgs EventViewMsgs, viewChanges EventViewChanges, msgAdd ChangeMsgAdd, msgRemove ChangeMsgRemove, msgFlags ChangeMsgFlags, msgThread ChangeMsgThread, mailboxRemove ChangeMailboxRemove, mailboxAdd ChangeMailboxAdd, mailboxRename ChangeMailboxRename, mailboxCounts ChangeMailboxCounts, mailboxSpecialUse ChangeMailboxSpecialUse, mailboxKeywords ChangeMailboxKeywords, flags store.Flags) {