5 cryptorand "crypto/rand"
27 "golang.org/x/exp/maps"
29 "github.com/mjl-/bstore"
30 "github.com/mjl-/sherpa"
31 "github.com/mjl-/sherpadoc"
32 "github.com/mjl-/sherpaprom"
34 "github.com/mjl-/mox/dkim"
35 "github.com/mjl-/mox/dns"
36 "github.com/mjl-/mox/message"
37 "github.com/mjl-/mox/metrics"
38 "github.com/mjl-/mox/mox-"
39 "github.com/mjl-/mox/moxio"
40 "github.com/mjl-/mox/moxvar"
41 "github.com/mjl-/mox/mtasts"
42 "github.com/mjl-/mox/mtastsdb"
43 "github.com/mjl-/mox/queue"
44 "github.com/mjl-/mox/smtp"
45 "github.com/mjl-/mox/smtpclient"
46 "github.com/mjl-/mox/store"
47 "github.com/mjl-/mox/webauth"
51var webmailapiJSON []byte
54 maxMessageSize int64 // From listener.
55 cookiePath string // From listener.
56 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
59func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
60 err := json.Unmarshal(buf, &doc)
62 pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
67var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
69var sherpaHandlerOpts *sherpa.HandlerOpts
71func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
72 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
76 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
78 pkglog.Fatalx("creating sherpa prometheus collector", err)
81 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
83 _, err = makeSherpaHandler(0, "", false)
85 pkglog.Fatalx("sherpa handler", err)
89// LoginPrep returns a login token, and also sets it as cookie. Both must be
90// present in the call to Login.
91func (w Webmail) LoginPrep(ctx context.Context) string {
92 log := pkglog.WithContext(ctx)
93 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
96 _, err := cryptorand.Read(data[:])
97 xcheckf(ctx, err, "generate token")
98 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
100 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
105// Login returns a session token for the credentials, or fails with error code
106// "user:badLogin". Call LoginPrep to get a loginToken.
107func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
108 log := pkglog.WithContext(ctx)
109 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
111 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
112 if _, ok := err.(*sherpa.Error); ok {
115 xcheckf(ctx, err, "login")
119// Logout invalidates the session token.
120func (w Webmail) Logout(ctx context.Context) {
121 log := pkglog.WithContext(ctx)
122 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
124 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
125 xcheckf(ctx, err, "logout")
128// Token returns a token to use for an SSE connection. A token can only be used for
129// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
130// with at most 10 unused tokens (the most recently created) per account.
131func (Webmail) Token(ctx context.Context) string {
132 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
133 return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress, reqInfo.SessionToken)
136// Requests sends a new request for an open SSE connection. Any currently active
137// request for the connection will be canceled, but this is done asynchrously, so
138// the SSE connection may still send results for the previous request. Callers
139// should take care to ignore such results. If req.Cancel is set, no new request is
141func (Webmail) Request(ctx context.Context, req Request) {
142 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
144 if !req.Cancel && req.Page.Count <= 0 {
145 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
148 sse, ok := sseGet(req.SSEID, reqInfo.AccountName)
150 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
155// ParsedMessage returns enough to render the textual body of a message. It is
156// assumed the client already has other fields through MessageItem.
157func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
158 log := pkglog.WithContext(ctx)
159 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
160 acc, err := store.OpenAccount(log, reqInfo.AccountName)
161 xcheckf(ctx, err, "open account")
164 log.Check(err, "closing account")
168 xdbread(ctx, acc, func(tx *bstore.Tx) {
169 m = xmessageID(ctx, tx, msgID)
172 state := msgState{acc: acc}
174 pm, err = parsedMessage(log, m, &state, true, false)
175 xcheckf(ctx, err, "parsing message")
179// Attachment is a MIME part is an existing message that is not intended as
180// viewable text or HTML part.
181type Attachment struct {
182 Path []int // Indices into top-level message.Part.Parts.
184 // File name based on "name" attribute of "Content-Type", or the "filename"
185 // attribute of "Content-Disposition".
191// SubmitMessage is an email message to be sent to one or more recipients.
192// Addresses are formatted as just email address, or with a name like "name
194type SubmitMessage struct {
202 ForwardAttachments ForwardAttachments
204 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
205 ReplyTo string // If non-empty, Reply-To header to add to message.
206 UserAgent string // User-Agent header added if not empty.
207 RequireTLS *bool // For "Require TLS" extension during delivery.
208 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
211// ForwardAttachments references attachments by a list of message.Part paths.
212type ForwardAttachments struct {
213 MessageID int64 // Only relevant if MessageID is not 0.
214 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
217// File is a new attachment (not from an existing message that is being
218// forwarded) to send with a SubmitMessage.
221 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
224// parseAddress expects either a plain email address like "user@domain", or a
225// single address as used in a message header, like "name <user@domain>".
226func parseAddress(msghdr string) (message.NameAddress, error) {
227 a, err := mail.ParseAddress(msghdr)
229 return message.NameAddress{}, err
233 path, err := smtp.ParseAddress(a.Address)
235 return message.NameAddress{}, err
237 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
240func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
242 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
244 mb := store.Mailbox{ID: mailboxID}
246 if err == bstore.ErrAbsent {
247 xcheckuserf(ctx, err, "getting mailbox")
249 xcheckf(ctx, err, "getting mailbox")
253// xmessageID returns a non-expunged message or panics with a sherpa error.
254func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
256 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
258 m := store.Message{ID: messageID}
260 if err == bstore.ErrAbsent {
261 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
262 } else if err == nil && m.Expunged {
263 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
265 xcheckf(ctx, err, "getting message")
269// MessageSubmit sends a message by submitting it the outgoing email queue. The
270// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
271// Bcc message header.
273// If a Sent mailbox is configured, messages are added to it after submitting
274// to the delivery queue.
275func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
276 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\(
278 // 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.
280 // Prevent any accidental control characters, or attempts at getting bare \r or \n
282 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
283 for _, s := range l {
284 for _, c := range s {
286 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
292 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
293 log := pkglog.WithContext(ctx).With(slog.String("account", reqInfo.AccountName))
294 acc, err := store.OpenAccount(log, reqInfo.AccountName)
295 xcheckf(ctx, err, "open account")
298 log.Check(err, "closing account")
301 log.Debug("message submit")
303 fromAddr, err := parseAddress(m.From)
304 xcheckuserf(ctx, err, "parsing From address")
306 var replyTo *message.NameAddress
308 a, err := parseAddress(m.ReplyTo)
309 xcheckuserf(ctx, err, "parsing Reply-To address")
313 var recipients []smtp.Address
315 var toAddrs []message.NameAddress
316 for _, s := range m.To {
317 addr, err := parseAddress(s)
318 xcheckuserf(ctx, err, "parsing To address")
319 toAddrs = append(toAddrs, addr)
320 recipients = append(recipients, addr.Address)
323 var ccAddrs []message.NameAddress
324 for _, s := range m.Cc {
325 addr, err := parseAddress(s)
326 xcheckuserf(ctx, err, "parsing Cc address")
327 ccAddrs = append(ccAddrs, addr)
328 recipients = append(recipients, addr.Address)
331 for _, s := range m.Bcc {
332 addr, err := parseAddress(s)
333 xcheckuserf(ctx, err, "parsing Bcc address")
334 recipients = append(recipients, addr.Address)
337 // Check if from address is allowed for account.
338 fromAccName, _, _, err := mox.FindAccount(fromAddr.Address.Localpart, fromAddr.Address.Domain, false)
339 if err == nil && fromAccName != reqInfo.AccountName {
340 err = mox.ErrAccountNotFound
342 if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
343 metricSubmission.WithLabelValues("badfrom").Inc()
344 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
346 xcheckf(ctx, err, "checking if from address is allowed")
348 if len(recipients) == 0 {
349 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
352 // Check outgoing message rate limit.
353 xdbread(ctx, acc, func(tx *bstore.Tx) {
354 rcpts := make([]smtp.Path, len(recipients))
355 for i, r := range recipients {
356 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
358 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
360 metricSubmission.WithLabelValues("messagelimiterror").Inc()
361 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
362 } else if rcptlimit >= 0 {
363 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
364 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
366 xcheckf(ctx, err, "checking send limit")
369 has8bit := false // We update this later on.
371 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
373 for _, a := range recipients {
374 if a.Localpart.IsInternational() {
379 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
380 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
384 // Create file to compose message into.
385 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
386 xcheckf(ctx, err, "creating temporary file for message")
387 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
389 // If writing to the message file fails, we abort immediately.
390 xc := message.NewComposer(dataFile, w.maxMessageSize)
396 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
397 xcheckuserf(ctx, err, "making message")
398 } else if ok && errors.Is(err, message.ErrCompose) {
399 xcheckf(ctx, err, "making message")
404 // 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
406 // Each queued message gets a Received header.
407 // We don't have access to the local IP for adding.
408 // We cannot use VIA, because there is no registered method. We would like to use
409 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
410 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
411 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
412 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
413 recvHdrFor := func(rcptTo string) string {
414 recvHdr := &message.HeaderWriter{}
415 // For additional Received-header clauses, see:
416 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
417 // Note: we don't have "via" or "with", there is no registered for webmail.
418 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
419 if reqInfo.Request.TLS != nil {
420 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
422 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
423 return recvHdr.String()
426 // Outer message headers.
427 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
429 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
431 xc.HeaderAddrs("To", toAddrs)
432 xc.HeaderAddrs("Cc", ccAddrs)
434 xc.Subject(m.Subject)
437 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
438 xc.Header("Message-Id", messageID)
439 xc.Header("Date", time.Now().Format(message.RFC5322Z))
440 // Add In-Reply-To and References headers.
441 if m.ResponseMessageID > 0 {
442 xdbread(ctx, acc, func(tx *bstore.Tx) {
443 rm := xmessageID(ctx, tx, m.ResponseMessageID)
444 msgr := acc.MessageReader(rm)
447 log.Check(err, "closing message reader")
449 rp, err := rm.LoadPart(msgr)
450 xcheckf(ctx, err, "load parsed message")
451 h, err := rp.Header()
452 xcheckf(ctx, err, "parsing header")
454 if rp.Envelope == nil {
457 xc.Header("In-Reply-To", rp.Envelope.MessageID)
458 ref := h.Get("References")
460 ref = h.Get("In-Reply-To")
463 xc.Header("References", ref+"\r\n\t"+rp.Envelope.MessageID)
465 xc.Header("References", rp.Envelope.MessageID)
469 if m.UserAgent != "" {
470 xc.Header("User-Agent", m.UserAgent)
472 if m.RequireTLS != nil && !*m.RequireTLS {
473 xc.Header("TLS-Required", "No")
475 xc.Header("MIME-Version", "1.0")
477 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
478 mp := multipart.NewWriter(xc)
479 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
482 textBody, ct, cte := xc.TextPart(m.TextBody)
483 textHdr := textproto.MIMEHeader{}
484 textHdr.Set("Content-Type", ct)
485 textHdr.Set("Content-Transfer-Encoding", cte)
487 textp, err := mp.CreatePart(textHdr)
488 xcheckf(ctx, err, "adding text part to message")
489 _, err = textp.Write(textBody)
490 xcheckf(ctx, err, "writing text part")
492 xaddPart := func(ct, filename string) io.Writer {
493 ahdr := textproto.MIMEHeader{}
494 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
496 ahdr.Set("Content-Type", ct)
497 ahdr.Set("Content-Transfer-Encoding", "base64")
498 ahdr.Set("Content-Disposition", cd)
499 ap, err := mp.CreatePart(ahdr)
500 xcheckf(ctx, err, "adding attachment part to message")
504 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
505 ap := xaddPart(ct, filename)
507 for len(base64Data) > 0 {
513 line, base64Data = base64Data[:n], base64Data[n:]
514 _, err := ap.Write(line)
515 xcheckf(ctx, err, "writing attachment")
516 _, err = ap.Write([]byte("\r\n"))
517 xcheckf(ctx, err, "writing attachment")
521 xaddAttachment := func(ct, filename string, r io.Reader) {
522 ap := xaddPart(ct, filename)
523 wc := moxio.Base64Writer(ap)
524 _, err := io.Copy(wc, r)
525 xcheckf(ctx, err, "adding attachment")
527 xcheckf(ctx, err, "flushing attachment")
530 for _, a := range m.Attachments {
532 if !strings.HasPrefix(s, "data:") {
533 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
536 t := strings.SplitN(s, ",", 2)
538 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
540 if !strings.HasSuffix(t[0], "base64") {
541 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
543 ct := strings.TrimSuffix(t[0], "base64")
544 ct = strings.TrimSuffix(ct, ";")
546 ct = "application/octet-stream"
548 filename := a.Filename
550 filename = "unnamed.bin"
552 params := map[string]string{"name": filename}
553 ct = mime.FormatMediaType(ct, params)
555 // Ensure base64 is valid, then we'll write the original string.
556 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
557 xcheckuserf(ctx, err, "parsing attachment as base64")
559 xaddAttachmentBase64(ct, filename, []byte(t[1]))
562 if len(m.ForwardAttachments.Paths) > 0 {
563 acc.WithRLock(func() {
564 xdbread(ctx, acc, func(tx *bstore.Tx) {
565 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
566 msgr := acc.MessageReader(fm)
569 log.Check(err, "closing message reader")
572 fp, err := fm.LoadPart(msgr)
573 xcheckf(ctx, err, "load parsed message")
575 for _, path := range m.ForwardAttachments.Paths {
577 for _, xp := range path {
578 if xp < 0 || xp >= len(ap.Parts) {
579 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
584 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
586 filename = "unnamed.bin"
588 params := map[string]string{"name": filename}
589 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
590 params["charset"] = pcharset
592 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
593 ct = mime.FormatMediaType(ct, params)
594 xaddAttachment(ct, filename, ap.Reader())
601 xcheckf(ctx, err, "writing mime multipart")
603 textBody, ct, cte := xc.TextPart(m.TextBody)
604 xc.Header("Content-Type", ct)
605 xc.Header("Content-Transfer-Encoding", cte)
607 xc.Write([]byte(textBody))
612 // Add DKIM-Signature headers.
614 fd := fromAddr.Address.Domain
615 confDom, _ := mox.Conf.Domain(fd)
616 selectors := mox.DKIMSelectors(confDom.DKIM)
617 if len(selectors) > 0 {
618 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
620 metricServerErrors.WithLabelValues("dkimsign").Inc()
622 xcheckf(ctx, err, "sign dkim")
624 msgPrefix = dkimHeaders
627 fromPath := smtp.Path{
628 Localpart: fromAddr.Address.Localpart,
629 IPDomain: dns.IPDomain{Domain: fromAddr.Address.Domain},
631 qml := make([]queue.Msg, len(recipients))
633 for i, rcpt := range recipients {
634 // Don't use per-recipient unique message prefix when multiple recipients are
635 // present, or the queue cannot deliver it in a single smtp transaction.
637 if len(recipients) == 1 {
638 recvRcpt = rcpt.Pack(smtputf8)
640 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
641 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
643 Localpart: rcpt.Localpart,
644 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
646 qm := queue.MakeMsg(fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now)
647 if m.FutureRelease != nil {
648 ival := time.Until(*m.FutureRelease)
650 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
651 } else if ival > queue.FutureReleaseIntervalMax {
652 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
654 qm.NextAttempt = *m.FutureRelease
655 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
656 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
660 err = queue.Add(ctx, log, reqInfo.AccountName, dataFile, qml...)
662 metricSubmission.WithLabelValues("queueerror").Inc()
664 xcheckf(ctx, err, "adding messages to the delivery queue")
665 metricSubmission.WithLabelValues("ok").Inc()
667 var modseq store.ModSeq // Only set if needed.
669 // Append message to Sent mailbox and mark original messages as answered/forwarded.
670 acc.WithRLock(func() {
671 var changes []store.Change
675 if x := recover(); x != nil {
677 metricServerErrors.WithLabelValues("submit").Inc()
682 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
683 if m.ResponseMessageID > 0 {
684 rm := xmessageID(ctx, tx, m.ResponseMessageID)
691 if !rm.Junk && !rm.Notjunk {
694 if rm.Flags != oflags {
695 modseq, err = acc.NextModSeq(tx)
696 xcheckf(ctx, err, "next modseq")
698 err := tx.Update(&rm)
699 xcheckf(ctx, err, "updating flags of replied/forwarded message")
700 changes = append(changes, rm.ChangeFlags(oflags))
702 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
703 xcheckf(ctx, err, "retraining messages after reply/forward")
707 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
708 if err == bstore.ErrAbsent {
709 // There is no mailbox designated as Sent mailbox, so we're done.
712 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
715 modseq, err = acc.NextModSeq(tx)
716 xcheckf(ctx, err, "next modseq")
719 sentm := store.Message{
722 MailboxID: sentmb.ID,
723 MailboxOrigID: sentmb.ID,
724 Flags: store.Flags{Notjunk: true, Seen: true},
725 Size: int64(len(msgPrefix)) + xc.Size,
726 MsgPrefix: []byte(msgPrefix),
729 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
730 xcheckf(ctx, err, "checking quota")
732 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
735 // Update mailbox before delivery, which changes uidnext.
736 sentmb.Add(sentm.MailboxCounts())
737 err = tx.Update(&sentmb)
738 xcheckf(ctx, err, "updating sent mailbox for counts")
740 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
742 metricSubmission.WithLabelValues("storesenterror").Inc()
745 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
747 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
750 store.BroadcastChanges(acc, changes)
754// MessageMove moves messages to another mailbox. If the message is already in
755// the mailbox an error is returned.
756func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
757 log := pkglog.WithContext(ctx)
758 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
759 acc, err := store.OpenAccount(log, reqInfo.AccountName)
760 xcheckf(ctx, err, "open account")
763 log.Check(err, "closing account")
766 acc.WithRLock(func() {
767 retrain := make([]store.Message, 0, len(messageIDs))
768 removeChanges := map[int64]store.ChangeRemoveUIDs{}
769 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
770 changes := make([]store.Change, 0, len(messageIDs)+3)
772 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
773 var mbSrc store.Mailbox
774 var modseq store.ModSeq
776 mbDst := xmailboxID(ctx, tx, mailboxID)
778 if len(messageIDs) == 0 {
782 keywords := map[string]struct{}{}
784 for _, mid := range messageIDs {
785 m := xmessageID(ctx, tx, mid)
787 // We may have loaded this mailbox in the previous iteration of this loop.
788 if m.MailboxID != mbSrc.ID {
790 err = tx.Update(&mbSrc)
791 xcheckf(ctx, err, "updating source mailbox counts")
792 changes = append(changes, mbSrc.ChangeCounts())
794 mbSrc = xmailboxID(ctx, tx, m.MailboxID)
797 if mbSrc.ID == mailboxID {
798 // Client should filter out messages that are already in mailbox.
799 xcheckuserf(ctx, errors.New("already in destination mailbox"), "moving message")
803 modseq, err = acc.NextModSeq(tx)
804 xcheckf(ctx, err, "assigning next modseq")
807 ch := removeChanges[m.MailboxID]
808 ch.UIDs = append(ch.UIDs, m.UID)
810 ch.MailboxID = m.MailboxID
811 removeChanges[m.MailboxID] = ch
813 // Copy of message record that we'll insert when UID is freed up.
816 om.ID = 0 // Assign new ID.
819 mbSrc.Sub(m.MailboxCounts())
824 conf, _ := acc.Conf()
825 m.MailboxID = mbDst.ID
826 if m.IsReject && m.MailboxDestinedID != 0 {
827 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
828 // is used for reputation calculation during future deliveries.
829 m.MailboxOrigID = m.MailboxDestinedID
833 m.UID = mbDst.UIDNext
836 m.JunkFlagsForMailbox(mbDst, conf)
838 xcheckf(ctx, err, "updating moved message in database")
840 // Now that UID is unused, we can insert the old record again.
842 xcheckf(ctx, err, "inserting record for expunge after moving message")
844 mbDst.Add(m.MailboxCounts())
846 changes = append(changes, m.ChangeAddUID())
847 retrain = append(retrain, m)
849 for _, kw := range m.Keywords {
850 keywords[kw] = struct{}{}
854 err = tx.Update(&mbSrc)
855 xcheckf(ctx, err, "updating source mailbox counts")
857 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
859 // Ensure destination mailbox has keywords of the moved messages.
861 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
863 changes = append(changes, mbDst.ChangeKeywords())
866 err = tx.Update(&mbDst)
867 xcheckf(ctx, err, "updating mailbox with uidnext")
869 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
870 xcheckf(ctx, err, "retraining messages after move")
873 // Ensure UIDs of the removed message are in increasing order. It is quite common
874 // for all messages to be from a single source mailbox, meaning this is just one
875 // change, for which we preallocated space.
876 for _, ch := range removeChanges {
877 sort.Slice(ch.UIDs, func(i, j int) bool {
878 return ch.UIDs[i] < ch.UIDs[j]
880 changes = append(changes, ch)
882 store.BroadcastChanges(acc, changes)
886// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
887func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
888 log := pkglog.WithContext(ctx)
889 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
890 acc, err := store.OpenAccount(log, reqInfo.AccountName)
891 xcheckf(ctx, err, "open account")
894 log.Check(err, "closing account")
897 if len(messageIDs) == 0 {
901 acc.WithWLock(func() {
902 removeChanges := map[int64]store.ChangeRemoveUIDs{}
903 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
905 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
906 var modseq store.ModSeq
908 remove := make([]store.Message, 0, len(messageIDs))
911 for _, mid := range messageIDs {
912 m := xmessageID(ctx, tx, mid)
915 if m.MailboxID != mb.ID {
917 err := tx.Update(&mb)
918 xcheckf(ctx, err, "updating mailbox counts")
919 changes = append(changes, mb.ChangeCounts())
921 mb = xmailboxID(ctx, tx, m.MailboxID)
924 qmr := bstore.QueryTx[store.Recipient](tx)
925 qmr.FilterEqual("MessageID", m.ID)
926 _, err = qmr.Delete()
927 xcheckf(ctx, err, "removing message recipients")
929 mb.Sub(m.MailboxCounts())
932 modseq, err = acc.NextModSeq(tx)
933 xcheckf(ctx, err, "assigning next modseq")
938 xcheckf(ctx, err, "marking message as expunged")
940 ch := removeChanges[m.MailboxID]
941 ch.UIDs = append(ch.UIDs, m.UID)
942 ch.MailboxID = m.MailboxID
944 removeChanges[m.MailboxID] = ch
945 remove = append(remove, m)
949 err := tx.Update(&mb)
950 xcheckf(ctx, err, "updating count in mailbox")
951 changes = append(changes, mb.ChangeCounts())
954 err = acc.AddMessageSize(log, tx, -totalSize)
955 xcheckf(ctx, err, "updating disk usage")
957 // Mark removed messages as not needing training, then retrain them, so if they
958 // were trained, they get untrained.
959 for i := range remove {
960 remove[i].Junk = false
961 remove[i].Notjunk = false
963 err = acc.RetrainMessages(ctx, log, tx, remove, true)
964 xcheckf(ctx, err, "untraining deleted messages")
967 for _, ch := range removeChanges {
968 sort.Slice(ch.UIDs, func(i, j int) bool {
969 return ch.UIDs[i] < ch.UIDs[j]
971 changes = append(changes, ch)
973 store.BroadcastChanges(acc, changes)
976 for _, mID := range messageIDs {
977 p := acc.MessagePath(mID)
979 log.Check(err, "removing message file for expunge")
983// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
984// flags should be lower-case, but will be converted and verified.
985func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
986 log := pkglog.WithContext(ctx)
987 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
988 acc, err := store.OpenAccount(log, reqInfo.AccountName)
989 xcheckf(ctx, err, "open account")
992 log.Check(err, "closing account")
995 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
996 xcheckuserf(ctx, err, "parsing flags")
998 acc.WithRLock(func() {
999 var changes []store.Change
1001 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1002 var modseq store.ModSeq
1003 var retrain []store.Message
1004 var mb, origmb store.Mailbox
1006 for _, mid := range messageIDs {
1007 m := xmessageID(ctx, tx, mid)
1009 if mb.ID != m.MailboxID {
1011 err := tx.Update(&mb)
1012 xcheckf(ctx, err, "updating mailbox")
1013 if mb.MailboxCounts != origmb.MailboxCounts {
1014 changes = append(changes, mb.ChangeCounts())
1016 if mb.KeywordsChanged(origmb) {
1017 changes = append(changes, mb.ChangeKeywords())
1020 mb = xmailboxID(ctx, tx, m.MailboxID)
1023 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
1025 mb.Sub(m.MailboxCounts())
1027 m.Flags = m.Flags.Set(flags, flags)
1029 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
1030 mb.Add(m.MailboxCounts())
1032 if m.Flags == oflags && !kwChanged {
1037 modseq, err = acc.NextModSeq(tx)
1038 xcheckf(ctx, err, "assigning next modseq")
1042 xcheckf(ctx, err, "updating message")
1044 changes = append(changes, m.ChangeFlags(oflags))
1045 retrain = append(retrain, m)
1049 err := tx.Update(&mb)
1050 xcheckf(ctx, err, "updating mailbox")
1051 if mb.MailboxCounts != origmb.MailboxCounts {
1052 changes = append(changes, mb.ChangeCounts())
1054 if mb.KeywordsChanged(origmb) {
1055 changes = append(changes, mb.ChangeKeywords())
1059 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1060 xcheckf(ctx, err, "retraining messages")
1063 store.BroadcastChanges(acc, changes)
1067// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1068func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1069 log := pkglog.WithContext(ctx)
1070 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1071 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1072 xcheckf(ctx, err, "open account")
1075 log.Check(err, "closing account")
1078 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
1079 xcheckuserf(ctx, err, "parsing flags")
1081 acc.WithRLock(func() {
1082 var retrain []store.Message
1083 var changes []store.Change
1085 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1086 var modseq store.ModSeq
1087 var mb, origmb store.Mailbox
1089 for _, mid := range messageIDs {
1090 m := xmessageID(ctx, tx, mid)
1092 if mb.ID != m.MailboxID {
1094 err := tx.Update(&mb)
1095 xcheckf(ctx, err, "updating counts for mailbox")
1096 if mb.MailboxCounts != origmb.MailboxCounts {
1097 changes = append(changes, mb.ChangeCounts())
1099 // note: cannot remove keywords from mailbox by removing keywords from message.
1101 mb = xmailboxID(ctx, tx, m.MailboxID)
1106 mb.Sub(m.MailboxCounts())
1107 m.Flags = m.Flags.Set(flags, store.Flags{})
1109 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
1110 mb.Add(m.MailboxCounts())
1112 if m.Flags == oflags && !changed {
1117 modseq, err = acc.NextModSeq(tx)
1118 xcheckf(ctx, err, "assigning next modseq")
1122 xcheckf(ctx, err, "updating message")
1124 changes = append(changes, m.ChangeFlags(oflags))
1125 retrain = append(retrain, m)
1129 err := tx.Update(&mb)
1130 xcheckf(ctx, err, "updating keywords in mailbox")
1131 if mb.MailboxCounts != origmb.MailboxCounts {
1132 changes = append(changes, mb.ChangeCounts())
1134 // note: cannot remove keywords from mailbox by removing keywords from message.
1137 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1138 xcheckf(ctx, err, "retraining messages")
1141 store.BroadcastChanges(acc, changes)
1145// MailboxCreate creates a new mailbox.
1146func (Webmail) MailboxCreate(ctx context.Context, name string) {
1147 log := pkglog.WithContext(ctx)
1148 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1149 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1150 xcheckf(ctx, err, "open account")
1153 log.Check(err, "closing account")
1156 name, _, err = store.CheckMailboxName(name, false)
1157 xcheckuserf(ctx, err, "checking mailbox name")
1159 acc.WithWLock(func() {
1160 var changes []store.Change
1161 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1164 changes, _, exists, err = acc.MailboxCreate(tx, name)
1166 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1168 xcheckf(ctx, err, "creating mailbox")
1171 store.BroadcastChanges(acc, changes)
1175// MailboxDelete deletes a mailbox and all its messages.
1176func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1177 log := pkglog.WithContext(ctx)
1178 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1179 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1180 xcheckf(ctx, err, "open account")
1183 log.Check(err, "closing account")
1186 // Messages to remove after having broadcasted the removal of messages.
1187 var removeMessageIDs []int64
1189 acc.WithWLock(func() {
1190 var changes []store.Change
1192 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1193 mb := xmailboxID(ctx, tx, mailboxID)
1194 if mb.Name == "Inbox" {
1195 // Inbox is special in IMAP and cannot be removed.
1196 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1199 var hasChildren bool
1201 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1203 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1205 xcheckf(ctx, err, "deleting mailbox")
1208 store.BroadcastChanges(acc, changes)
1211 for _, mID := range removeMessageIDs {
1212 p := acc.MessagePath(mID)
1214 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1218// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1219// its child mailboxes.
1220func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1221 log := pkglog.WithContext(ctx)
1222 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1223 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1224 xcheckf(ctx, err, "open account")
1227 log.Check(err, "closing account")
1230 var expunged []store.Message
1232 acc.WithWLock(func() {
1233 var changes []store.Change
1235 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1236 mb := xmailboxID(ctx, tx, mailboxID)
1238 modseq, err := acc.NextModSeq(tx)
1239 xcheckf(ctx, err, "next modseq")
1241 // Mark messages as expunged.
1242 qm := bstore.QueryTx[store.Message](tx)
1243 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1244 qm.FilterEqual("Expunged", false)
1246 qm.Gather(&expunged)
1247 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1248 xcheckf(ctx, err, "deleting messages")
1250 // Remove Recipients.
1251 anyIDs := make([]any, len(expunged))
1252 for i, m := range expunged {
1255 qmr := bstore.QueryTx[store.Recipient](tx)
1256 qmr.FilterEqual("MessageID", anyIDs...)
1257 _, err = qmr.Delete()
1258 xcheckf(ctx, err, "removing message recipients")
1260 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1262 uids := make([]store.UID, len(expunged))
1263 for i, m := range expunged {
1264 m.Expunged = false // Gather returns updated values.
1265 mb.Sub(m.MailboxCounts())
1269 expunged[i].Junk = false
1270 expunged[i].Notjunk = false
1273 err = tx.Update(&mb)
1274 xcheckf(ctx, err, "updating mailbox for counts")
1276 err = acc.AddMessageSize(log, tx, -totalSize)
1277 xcheckf(ctx, err, "updating disk usage")
1279 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1280 xcheckf(ctx, err, "retraining expunged messages")
1282 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1283 changes = []store.Change{chremove, mb.ChangeCounts()}
1286 store.BroadcastChanges(acc, changes)
1289 for _, m := range expunged {
1290 p := acc.MessagePath(m.ID)
1292 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1296// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1297// ID and its messages are unchanged.
1298func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1299 log := pkglog.WithContext(ctx)
1300 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1301 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1302 xcheckf(ctx, err, "open account")
1305 log.Check(err, "closing account")
1308 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1309 // standard. We can just say no.
1310 newName, _, err = store.CheckMailboxName(newName, false)
1311 xcheckuserf(ctx, err, "checking new mailbox name")
1313 acc.WithWLock(func() {
1314 var changes []store.Change
1316 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1317 mbsrc := xmailboxID(ctx, tx, mailboxID)
1319 var isInbox, notExists, alreadyExists bool
1320 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1321 if isInbox || notExists || alreadyExists {
1322 xcheckuserf(ctx, err, "renaming mailbox")
1324 xcheckf(ctx, err, "renaming mailbox")
1327 store.BroadcastChanges(acc, changes)
1331// CompleteRecipient returns autocomplete matches for a recipient, returning the
1332// matches, most recently used first, and whether this is the full list and further
1333// requests for longer prefixes aren't necessary.
1334func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1335 log := pkglog.WithContext(ctx)
1336 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1337 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1338 xcheckf(ctx, err, "open account")
1341 log.Check(err, "closing account")
1344 search = strings.ToLower(search)
1346 var matches []string
1348 acc.WithRLock(func() {
1349 xdbread(ctx, acc, func(tx *bstore.Tx) {
1354 seen := map[key]bool{}
1356 q := bstore.QueryTx[store.Recipient](tx)
1358 err := q.ForEach(func(r store.Recipient) error {
1359 k := key{r.Localpart, r.Domain}
1363 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1364 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1365 if !strings.Contains(strings.ToLower(address), search) {
1368 if len(matches) >= 20 {
1370 return bstore.StopForEach
1373 // Look in the message that was sent for a name along with the address.
1374 m := store.Message{ID: r.MessageID}
1376 xcheckf(ctx, err, "get sent message")
1377 if !m.Expunged && m.ParsedBuf != nil {
1378 var part message.Part
1379 err := json.Unmarshal(m.ParsedBuf, &part)
1380 xcheckf(ctx, err, "parsing part")
1382 dom, err := dns.ParseDomain(r.Domain)
1383 xcheckf(ctx, err, "parsing domain of recipient")
1387 checkAddrs := func(l []message.Address) {
1391 for _, a := range l {
1392 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1394 address = addressString(a, false)
1399 if part.Envelope != nil {
1400 env := part.Envelope
1407 matches = append(matches, address)
1411 xcheckf(ctx, err, "listing recipients")
1417// addressString returns an address into a string as it could be used in a message header.
1418func addressString(a message.Address, smtputf8 bool) string {
1420 dom, err := dns.ParseDomain(a.Host)
1422 if smtputf8 && dom.Unicode != "" {
1428 s := "<" + a.User + "@" + host + ">"
1430 // todo: properly encoded/escaped name
1431 s = a.Name + " " + s
1436// MailboxSetSpecialUse sets the special use flags of a mailbox.
1437func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1438 log := pkglog.WithContext(ctx)
1439 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1440 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1441 xcheckf(ctx, err, "open account")
1444 log.Check(err, "closing account")
1447 acc.WithWLock(func() {
1448 var changes []store.Change
1450 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1451 xmb := xmailboxID(ctx, tx, mb.ID)
1453 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1454 // we set, we clear it for the mailbox(es) that had it, if any.
1455 clearPrevious := func(clear bool, specialUse string) {
1459 var ombl []store.Mailbox
1460 q := bstore.QueryTx[store.Mailbox](tx)
1461 q.FilterNotEqual("ID", mb.ID)
1462 q.FilterEqual(specialUse, true)
1464 _, err := q.UpdateField(specialUse, false)
1465 xcheckf(ctx, err, "updating previous special-use mailboxes")
1467 for _, omb := range ombl {
1468 changes = append(changes, omb.ChangeSpecialUse())
1471 clearPrevious(mb.Archive, "Archive")
1472 clearPrevious(mb.Draft, "Draft")
1473 clearPrevious(mb.Junk, "Junk")
1474 clearPrevious(mb.Sent, "Sent")
1475 clearPrevious(mb.Trash, "Trash")
1477 xmb.SpecialUse = mb.SpecialUse
1478 err = tx.Update(&xmb)
1479 xcheckf(ctx, err, "updating special-use flags for mailbox")
1480 changes = append(changes, xmb.ChangeSpecialUse())
1483 store.BroadcastChanges(acc, changes)
1487// ThreadCollapse saves the ThreadCollapse field for the messages and its
1488// children. The messageIDs are typically thread roots. But not all roots
1489// (without parent) of a thread need to have the same collapsed state.
1490func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1491 log := pkglog.WithContext(ctx)
1492 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1493 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1494 xcheckf(ctx, err, "open account")
1497 log.Check(err, "closing account")
1500 if len(messageIDs) == 0 {
1501 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1504 acc.WithWLock(func() {
1505 changes := make([]store.Change, 0, len(messageIDs))
1506 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1507 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1508 // (child) messages. Further refined in FilterFn.
1509 threadIDs := map[int64]struct{}{}
1510 msgIDs := map[int64]struct{}{}
1511 for _, id := range messageIDs {
1512 m := store.Message{ID: id}
1514 if err == bstore.ErrAbsent {
1515 xcheckuserf(ctx, err, "get message")
1517 xcheckf(ctx, err, "get message")
1518 threadIDs[m.ThreadID] = struct{}{}
1519 msgIDs[id] = struct{}{}
1522 var updated []store.Message
1523 q := bstore.QueryTx[store.Message](tx)
1524 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1525 q.FilterNotEqual("ThreadCollapsed", collapse)
1526 q.FilterFn(func(tm store.Message) bool {
1527 for _, id := range tm.ThreadParentIDs {
1528 if _, ok := msgIDs[id]; ok {
1532 _, ok := msgIDs[tm.ID]
1536 q.SortAsc("ID") // Consistent order for testing.
1537 _, err = q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1538 xcheckf(ctx, err, "updating collapse in database")
1540 for _, m := range updated {
1541 changes = append(changes, m.ChangeThread())
1544 store.BroadcastChanges(acc, changes)
1548// ThreadMute saves the ThreadMute field for the messages and their children.
1549// If messages are muted, they are also marked collapsed.
1550func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1551 log := pkglog.WithContext(ctx)
1552 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1553 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1554 xcheckf(ctx, err, "open account")
1557 log.Check(err, "closing account")
1560 if len(messageIDs) == 0 {
1561 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1564 acc.WithWLock(func() {
1565 changes := make([]store.Change, 0, len(messageIDs))
1566 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1567 threadIDs := map[int64]struct{}{}
1568 msgIDs := map[int64]struct{}{}
1569 for _, id := range messageIDs {
1570 m := store.Message{ID: id}
1572 if err == bstore.ErrAbsent {
1573 xcheckuserf(ctx, err, "get message")
1575 xcheckf(ctx, err, "get message")
1576 threadIDs[m.ThreadID] = struct{}{}
1577 msgIDs[id] = struct{}{}
1580 var updated []store.Message
1582 q := bstore.QueryTx[store.Message](tx)
1583 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1584 q.FilterFn(func(tm store.Message) bool {
1585 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1588 for _, id := range tm.ThreadParentIDs {
1589 if _, ok := msgIDs[id]; ok {
1593 _, ok := msgIDs[tm.ID]
1597 fields := map[string]any{"ThreadMuted": mute}
1599 fields["ThreadCollapsed"] = true
1601 _, err = q.UpdateFields(fields)
1602 xcheckf(ctx, err, "updating mute in database")
1604 for _, m := range updated {
1605 changes = append(changes, m.ChangeThread())
1608 store.BroadcastChanges(acc, changes)
1612// SecurityResult indicates whether a security feature is supported.
1613type SecurityResult string
1616 SecurityResultError SecurityResult = "error"
1617 SecurityResultNo SecurityResult = "no"
1618 SecurityResultYes SecurityResult = "yes"
1619 // Unknown whether supported. Finding out may only be (reasonably) possible when
1620 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1622 SecurityResultUnknown SecurityResult = "unknown"
1625// RecipientSecurity is a quick analysis of the security properties of delivery to
1626// the recipient (domain).
1627type RecipientSecurity struct {
1628 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1629 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1631 STARTTLS SecurityResult
1633 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1635 MTASTS SecurityResult
1637 // Whether MX lookup response was DNSSEC-signed.
1638 DNSSEC SecurityResult
1640 // Whether first delivery destination has DANE records.
1643 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1644 // Will be "unknown" if no delivery to the domain has been attempted yet.
1645 RequireTLS SecurityResult
1648// RecipientSecurity looks up security properties of the address in the
1649// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1650func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1651 resolver := dns.StrictResolver{Pkg: "webmail", Log: pkglog.WithContext(ctx).Logger}
1652 return recipientSecurity(ctx, resolver, messageAddressee)
1655// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1656func logPanic(ctx context.Context) {
1661 log := pkglog.WithContext(ctx)
1662 log.Error("recover from panic", slog.Any("panic", x))
1664 metrics.PanicInc(metrics.Webmail)
1667// separate function for testing with mocked resolver.
1668func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1669 log := pkglog.WithContext(ctx)
1671 rs := RecipientSecurity{
1672 SecurityResultUnknown,
1673 SecurityResultUnknown,
1674 SecurityResultUnknown,
1675 SecurityResultUnknown,
1676 SecurityResultUnknown,
1679 msgAddr, err := mail.ParseAddress(messageAddressee)
1681 return rs, fmt.Errorf("parsing message addressee: %v", err)
1684 addr, err := smtp.ParseAddress(msgAddr.Address)
1686 return rs, fmt.Errorf("parsing address: %v", err)
1689 var wg sync.WaitGroup
1697 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1698 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1699 rs.MTASTS = SecurityResultYes
1700 } else if err == nil {
1701 rs.MTASTS = SecurityResultNo
1703 rs.MTASTS = SecurityResultError
1713 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1715 rs.DNSSEC = SecurityResultError
1718 if origNextHopAuthentic && expandedNextHopAuthentic {
1719 rs.DNSSEC = SecurityResultYes
1721 rs.DNSSEC = SecurityResultNo
1724 if !origNextHopAuthentic {
1725 rs.DANE = SecurityResultNo
1729 // We're only looking at the first host to deliver to (typically first mx destination).
1730 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1731 return // Should not happen.
1735 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1736 // error result instead of no-DANE result.
1737 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, host, map[string][]net.IP{})
1739 rs.DANE = SecurityResultError
1743 rs.DANE = SecurityResultNo
1747 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1749 rs.DANE = SecurityResultError
1751 } else if daneRequired {
1752 rs.DANE = SecurityResultYes
1754 rs.DANE = SecurityResultNo
1758 // STARTTLS and RequireTLS
1759 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1760 acc, err := store.OpenAccount(log, reqInfo.AccountName)
1761 xcheckf(ctx, err, "open account")
1765 log.Check(err, "closing account")
1769 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1770 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1771 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1773 if err == bstore.ErrAbsent {
1775 } else if err != nil {
1776 rs.STARTTLS = SecurityResultError
1777 rs.RequireTLS = SecurityResultError
1778 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1782 rs.STARTTLS = SecurityResultYes
1784 rs.STARTTLS = SecurityResultNo
1787 rs.RequireTLS = SecurityResultYes
1789 rs.RequireTLS = SecurityResultNo
1793 xcheckf(ctx, err, "lookup recipient domain")
1795 // Close account as soon as possible, not after waiting for MTA-STS/DNSSEC/DANE
1796 // checks to complete, which can take a while.
1798 log.Check(err, "closing account")
1806// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1807func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1808 s, err := wordDecoder.DecodeHeader(text)
1809 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1813func slicesAny[T any](l []T) []any {
1814 r := make([]any, len(l))
1815 for i, v := range l {
1821// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
1822func (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) {