25 "golang.org/x/exp/maps"
27 "github.com/mjl-/bstore"
28 "github.com/mjl-/sherpa"
29 "github.com/mjl-/sherpadoc"
30 "github.com/mjl-/sherpaprom"
32 "github.com/mjl-/mox/dkim"
33 "github.com/mjl-/mox/dns"
34 "github.com/mjl-/mox/message"
35 "github.com/mjl-/mox/metrics"
36 "github.com/mjl-/mox/mlog"
37 "github.com/mjl-/mox/mox-"
38 "github.com/mjl-/mox/moxio"
39 "github.com/mjl-/mox/moxvar"
40 "github.com/mjl-/mox/mtasts"
41 "github.com/mjl-/mox/mtastsdb"
42 "github.com/mjl-/mox/queue"
43 "github.com/mjl-/mox/smtp"
44 "github.com/mjl-/mox/smtpclient"
45 "github.com/mjl-/mox/store"
49var webmailapiJSON []byte
52 maxMessageSize int64 // From listener.
55func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
56 err := json.Unmarshal(buf, &doc)
58 xlog.Fatalx("parsing webmail api docs", err, mlog.Field("api", api))
63var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
65var sherpaHandlerOpts *sherpa.HandlerOpts
67func makeSherpaHandler(maxMessageSize int64) (http.Handler, error) {
68 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize}, &webmailDoc, sherpaHandlerOpts)
72 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
74 xlog.Fatalx("creating sherpa prometheus collector", err)
77 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}
79 _, err = makeSherpaHandler(0)
81 xlog.Fatalx("sherpa handler", err)
85// Token returns a token to use for an SSE connection. A token can only be used for
86// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
87// with at most 10 unused tokens (the most recently created) per account.
88func (Webmail) Token(ctx context.Context) string {
89 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
90 return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress)
93// Requests sends a new request for an open SSE connection. Any currently active
94// request for the connection will be canceled, but this is done asynchrously, so
95// the SSE connection may still send results for the previous request. Callers
96// should take care to ignore such results. If req.Cancel is set, no new request is
98func (Webmail) Request(ctx context.Context, req Request) {
99 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
101 if !req.Cancel && req.Page.Count <= 0 {
102 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
105 sse, ok := sseGet(req.SSEID, reqInfo.AccountName)
107 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
112// ParsedMessage returns enough to render the textual body of a message. It is
113// assumed the client already has other fields through MessageItem.
114func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
115 log := xlog.WithContext(ctx)
116 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
117 acc, err := store.OpenAccount(reqInfo.AccountName)
118 xcheckf(ctx, err, "open account")
121 log.Check(err, "closing account")
125 xdbread(ctx, acc, func(tx *bstore.Tx) {
126 m = xmessageID(ctx, tx, msgID)
129 state := msgState{acc: acc}
131 pm, err = parsedMessage(log, m, &state, true, false)
132 xcheckf(ctx, err, "parsing message")
136// Attachment is a MIME part is an existing message that is not intended as
137// viewable text or HTML part.
138type Attachment struct {
139 Path []int // Indices into top-level message.Part.Parts.
141 // File name based on "name" attribute of "Content-Type", or the "filename"
142 // attribute of "Content-Disposition".
148// SubmitMessage is an email message to be sent to one or more recipients.
149// Addresses are formatted as just email address, or with a name like "name
151type SubmitMessage struct {
159 ForwardAttachments ForwardAttachments
161 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
162 ReplyTo string // If non-empty, Reply-To header to add to message.
163 UserAgent string // User-Agent header added if not empty.
164 RequireTLS *bool // For "Require TLS" extension during delivery.
167// ForwardAttachments references attachments by a list of message.Part paths.
168type ForwardAttachments struct {
169 MessageID int64 // Only relevant if MessageID is not 0.
170 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
173// File is a new attachment (not from an existing message that is being
174// forwarded) to send with a SubmitMessage.
177 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
180// parseAddress expects either a plain email address like "user@domain", or a
181// single address as used in a message header, like "name <user@domain>".
182func parseAddress(msghdr string) (message.NameAddress, error) {
183 a, err := mail.ParseAddress(msghdr)
185 return message.NameAddress{}, nil
189 path, err := smtp.ParseAddress(a.Address)
191 return message.NameAddress{}, err
193 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
196func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
198 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
200 mb := store.Mailbox{ID: mailboxID}
202 if err == bstore.ErrAbsent {
203 xcheckuserf(ctx, err, "getting mailbox")
205 xcheckf(ctx, err, "getting mailbox")
209// xmessageID returns a non-expunged message or panics with a sherpa error.
210func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
212 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
214 m := store.Message{ID: messageID}
216 if err == bstore.ErrAbsent {
217 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
218 } else if err == nil && m.Expunged {
219 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
221 xcheckf(ctx, err, "getting message")
225// MessageSubmit sends a message by submitting it the outgoing email queue. The
226// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
227// Bcc message header.
229// If a Sent mailbox is configured, messages are added to it after submitting
230// to the delivery queue.
231func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
232 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\(
234 // 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.
236 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
237 log := xlog.WithContext(ctx).Fields(mlog.Field("account", reqInfo.AccountName))
238 acc, err := store.OpenAccount(reqInfo.AccountName)
239 xcheckf(ctx, err, "open account")
242 log.Check(err, "closing account")
245 log.Debug("message submit")
247 fromAddr, err := parseAddress(m.From)
248 xcheckuserf(ctx, err, "parsing From address")
250 var replyTo *message.NameAddress
252 a, err := parseAddress(m.ReplyTo)
253 xcheckuserf(ctx, err, "parsing Reply-To address")
257 var recipients []smtp.Address
259 var toAddrs []message.NameAddress
260 for _, s := range m.To {
261 addr, err := parseAddress(s)
262 xcheckuserf(ctx, err, "parsing To address")
263 toAddrs = append(toAddrs, addr)
264 recipients = append(recipients, addr.Address)
267 var ccAddrs []message.NameAddress
268 for _, s := range m.Cc {
269 addr, err := parseAddress(s)
270 xcheckuserf(ctx, err, "parsing Cc address")
271 ccAddrs = append(ccAddrs, addr)
272 recipients = append(recipients, addr.Address)
275 for _, s := range m.Bcc {
276 addr, err := parseAddress(s)
277 xcheckuserf(ctx, err, "parsing Bcc address")
278 recipients = append(recipients, addr.Address)
281 // Check if from address is allowed for account.
282 fromAccName, _, _, err := mox.FindAccount(fromAddr.Address.Localpart, fromAddr.Address.Domain, false)
283 if err == nil && fromAccName != reqInfo.AccountName {
284 err = mox.ErrAccountNotFound
286 if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
287 metricSubmission.WithLabelValues("badfrom").Inc()
288 xcheckuserf(ctx, errors.New("address not found"), "looking from address for account")
290 xcheckf(ctx, err, "checking if from address is allowed")
292 if len(recipients) == 0 {
293 xcheckuserf(ctx, fmt.Errorf("no recipients"), "composing message")
296 // Check outgoing message rate limit.
297 xdbread(ctx, acc, func(tx *bstore.Tx) {
298 rcpts := make([]smtp.Path, len(recipients))
299 for i, r := range recipients {
300 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
302 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
304 metricSubmission.WithLabelValues("messagelimiterror").Inc()
305 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
306 } else if rcptlimit >= 0 {
307 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
308 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
310 xcheckf(ctx, err, "checking send limit")
313 has8bit := false // We update this later on.
315 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
317 for _, a := range recipients {
318 if a.Localpart.IsInternational() {
323 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
324 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
328 // Create file to compose message into.
329 dataFile, err := store.CreateMessageTemp("webmail-submit")
330 xcheckf(ctx, err, "creating temporary file for message")
331 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
333 // If writing to the message file fails, we abort immediately.
334 xc := message.NewComposer(dataFile, w.maxMessageSize)
340 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
341 xcheckuserf(ctx, err, "making message")
342 } else if ok && errors.Is(err, message.ErrCompose) {
343 xcheckf(ctx, err, "making message")
348 // 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
350 // Each queued message gets a Received header.
351 // We don't have access to the local IP for adding.
352 // We cannot use VIA, because there is no registered method. We would like to use
353 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
354 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
355 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
356 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
357 recvHdrFor := func(rcptTo string) string {
358 recvHdr := &message.HeaderWriter{}
359 // For additional Received-header clauses, see:
360 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
361 // Note: we don't have "via" or "with", there is no registered for webmail.
362 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
363 if reqInfo.Request.TLS != nil {
364 recvHdr.Add(" ", message.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
366 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
367 return recvHdr.String()
370 // Outer message headers.
371 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
373 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
375 xc.HeaderAddrs("To", toAddrs)
376 xc.HeaderAddrs("Cc", ccAddrs)
378 xc.Subject(m.Subject)
381 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
382 xc.Header("Message-Id", messageID)
383 xc.Header("Date", time.Now().Format(message.RFC5322Z))
384 // Add In-Reply-To and References headers.
385 if m.ResponseMessageID > 0 {
386 xdbread(ctx, acc, func(tx *bstore.Tx) {
387 rm := xmessageID(ctx, tx, m.ResponseMessageID)
388 msgr := acc.MessageReader(rm)
391 log.Check(err, "closing message reader")
393 rp, err := rm.LoadPart(msgr)
394 xcheckf(ctx, err, "load parsed message")
395 h, err := rp.Header()
396 xcheckf(ctx, err, "parsing header")
398 if rp.Envelope == nil {
401 xc.Header("In-Reply-To", rp.Envelope.MessageID)
402 ref := h.Get("References")
404 ref = h.Get("In-Reply-To")
407 xc.Header("References", ref+"\r\n\t"+rp.Envelope.MessageID)
409 xc.Header("References", rp.Envelope.MessageID)
413 if m.UserAgent != "" {
414 xc.Header("User-Agent", m.UserAgent)
416 if m.RequireTLS != nil && !*m.RequireTLS {
417 xc.Header("TLS-Required", "No")
419 xc.Header("MIME-Version", "1.0")
421 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
422 mp := multipart.NewWriter(xc)
423 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
426 textBody, ct, cte := xc.TextPart(m.TextBody)
427 textHdr := textproto.MIMEHeader{}
428 textHdr.Set("Content-Type", ct)
429 textHdr.Set("Content-Transfer-Encoding", cte)
431 textp, err := mp.CreatePart(textHdr)
432 xcheckf(ctx, err, "adding text part to message")
433 _, err = textp.Write(textBody)
434 xcheckf(ctx, err, "writing text part")
436 xaddPart := func(ct, filename string) io.Writer {
437 ahdr := textproto.MIMEHeader{}
438 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
440 ahdr.Set("Content-Type", ct)
441 ahdr.Set("Content-Transfer-Encoding", "base64")
442 ahdr.Set("Content-Disposition", cd)
443 ap, err := mp.CreatePart(ahdr)
444 xcheckf(ctx, err, "adding attachment part to message")
448 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
449 ap := xaddPart(ct, filename)
451 for len(base64Data) > 0 {
457 line, base64Data = base64Data[:n], base64Data[n:]
458 _, err := ap.Write(line)
459 xcheckf(ctx, err, "writing attachment")
460 _, err = ap.Write([]byte("\r\n"))
461 xcheckf(ctx, err, "writing attachment")
465 xaddAttachment := func(ct, filename string, r io.Reader) {
466 ap := xaddPart(ct, filename)
467 wc := moxio.Base64Writer(ap)
468 _, err := io.Copy(wc, r)
469 xcheckf(ctx, err, "adding attachment")
471 xcheckf(ctx, err, "flushing attachment")
474 for _, a := range m.Attachments {
476 if !strings.HasPrefix(s, "data:") {
477 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
480 t := strings.SplitN(s, ",", 2)
482 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
484 if !strings.HasSuffix(t[0], "base64") {
485 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
487 ct := strings.TrimSuffix(t[0], "base64")
488 ct = strings.TrimSuffix(ct, ";")
490 ct = "application/octet-stream"
492 filename := a.Filename
494 filename = "unnamed.bin"
496 params := map[string]string{"name": filename}
497 ct = mime.FormatMediaType(ct, params)
499 // Ensure base64 is valid, then we'll write the original string.
500 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
501 xcheckuserf(ctx, err, "parsing attachment as base64")
503 xaddAttachmentBase64(ct, filename, []byte(t[1]))
506 if len(m.ForwardAttachments.Paths) > 0 {
507 acc.WithRLock(func() {
508 xdbread(ctx, acc, func(tx *bstore.Tx) {
509 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
510 msgr := acc.MessageReader(fm)
513 log.Check(err, "closing message reader")
516 fp, err := fm.LoadPart(msgr)
517 xcheckf(ctx, err, "load parsed message")
519 for _, path := range m.ForwardAttachments.Paths {
521 for _, xp := range path {
522 if xp < 0 || xp >= len(ap.Parts) {
523 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
528 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
530 filename = "unnamed.bin"
532 params := map[string]string{"name": filename}
533 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
534 params["charset"] = pcharset
536 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
537 ct = mime.FormatMediaType(ct, params)
538 xaddAttachment(ct, filename, ap.Reader())
545 xcheckf(ctx, err, "writing mime multipart")
547 textBody, ct, cte := xc.TextPart(m.TextBody)
548 xc.Header("Content-Type", ct)
549 xc.Header("Content-Transfer-Encoding", cte)
551 xc.Write([]byte(textBody))
556 // Add DKIM-Signature headers.
558 fd := fromAddr.Address.Domain
559 confDom, _ := mox.Conf.Domain(fd)
560 if len(confDom.DKIM.Sign) > 0 {
561 dkimHeaders, err := dkim.Sign(ctx, fromAddr.Address.Localpart, fd, confDom.DKIM, smtputf8, dataFile)
563 metricServerErrors.WithLabelValues("dkimsign").Inc()
565 xcheckf(ctx, err, "sign dkim")
567 msgPrefix = dkimHeaders
570 fromPath := smtp.Path{
571 Localpart: fromAddr.Address.Localpart,
572 IPDomain: dns.IPDomain{Domain: fromAddr.Address.Domain},
574 for _, rcpt := range recipients {
575 rcptMsgPrefix := recvHdrFor(rcpt.Pack(smtputf8)) + msgPrefix
576 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
578 Localpart: rcpt.Localpart,
579 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
581 qm := queue.MakeMsg(reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS)
582 err := queue.Add(ctx, log, &qm, dataFile)
584 metricSubmission.WithLabelValues("queueerror").Inc()
586 xcheckf(ctx, err, "adding message to the delivery queue")
587 metricSubmission.WithLabelValues("ok").Inc()
590 var modseq store.ModSeq // Only set if needed.
592 // Append message to Sent mailbox and mark original messages as answered/forwarded.
593 acc.WithRLock(func() {
594 var changes []store.Change
598 if x := recover(); x != nil {
600 metricServerErrors.WithLabelValues("submit").Inc()
605 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
606 if m.ResponseMessageID > 0 {
607 rm := xmessageID(ctx, tx, m.ResponseMessageID)
614 if !rm.Junk && !rm.Notjunk {
617 if rm.Flags != oflags {
618 modseq, err = acc.NextModSeq(tx)
619 xcheckf(ctx, err, "next modseq")
621 err := tx.Update(&rm)
622 xcheckf(ctx, err, "updating flags of replied/forwarded message")
623 changes = append(changes, rm.ChangeFlags(oflags))
625 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
626 xcheckf(ctx, err, "retraining messages after reply/forward")
630 sentmb, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Sent", true).Get()
631 if err == bstore.ErrAbsent {
632 // There is no mailbox designated as Sent mailbox, so we're done.
635 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
638 modseq, err = acc.NextModSeq(tx)
639 xcheckf(ctx, err, "next modseq")
642 sentm := store.Message{
645 MailboxID: sentmb.ID,
646 MailboxOrigID: sentmb.ID,
647 Flags: store.Flags{Notjunk: true, Seen: true},
648 Size: int64(len(msgPrefix)) + xc.Size,
649 MsgPrefix: []byte(msgPrefix),
652 // Update mailbox before delivery, which changes uidnext.
653 sentmb.Add(sentm.MailboxCounts())
654 err = tx.Update(&sentmb)
655 xcheckf(ctx, err, "updating sent mailbox for counts")
657 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false)
659 metricSubmission.WithLabelValues("storesenterror").Inc()
662 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
664 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
667 store.BroadcastChanges(acc, changes)
671// MessageMove moves messages to another mailbox. If the message is already in
672// the mailbox an error is returned.
673func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
674 log := xlog.WithContext(ctx)
675 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
676 acc, err := store.OpenAccount(reqInfo.AccountName)
677 xcheckf(ctx, err, "open account")
680 log.Check(err, "closing account")
683 acc.WithRLock(func() {
684 retrain := make([]store.Message, 0, len(messageIDs))
685 removeChanges := map[int64]store.ChangeRemoveUIDs{}
686 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
687 changes := make([]store.Change, 0, len(messageIDs)+3)
689 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
690 var mbSrc store.Mailbox
691 var modseq store.ModSeq
693 mbDst := xmailboxID(ctx, tx, mailboxID)
695 if len(messageIDs) == 0 {
699 keywords := map[string]struct{}{}
701 for _, mid := range messageIDs {
702 m := xmessageID(ctx, tx, mid)
704 // We may have loaded this mailbox in the previous iteration of this loop.
705 if m.MailboxID != mbSrc.ID {
707 err = tx.Update(&mbSrc)
708 xcheckf(ctx, err, "updating source mailbox counts")
709 changes = append(changes, mbSrc.ChangeCounts())
711 mbSrc = xmailboxID(ctx, tx, m.MailboxID)
714 if mbSrc.ID == mailboxID {
715 // Client should filter out messages that are already in mailbox.
716 xcheckuserf(ctx, errors.New("already in destination mailbox"), "moving message")
720 modseq, err = acc.NextModSeq(tx)
721 xcheckf(ctx, err, "assigning next modseq")
724 ch := removeChanges[m.MailboxID]
725 ch.UIDs = append(ch.UIDs, m.UID)
727 ch.MailboxID = m.MailboxID
728 removeChanges[m.MailboxID] = ch
730 // Copy of message record that we'll insert when UID is freed up.
733 om.ID = 0 // Assign new ID.
736 mbSrc.Sub(m.MailboxCounts())
741 conf, _ := acc.Conf()
742 m.MailboxID = mbDst.ID
743 if m.IsReject && m.MailboxDestinedID != 0 {
744 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
745 // is used for reputation calculation during future deliveries.
746 m.MailboxOrigID = m.MailboxDestinedID
750 m.UID = mbDst.UIDNext
753 m.JunkFlagsForMailbox(mbDst, conf)
755 xcheckf(ctx, err, "updating moved message in database")
757 // Now that UID is unused, we can insert the old record again.
759 xcheckf(ctx, err, "inserting record for expunge after moving message")
761 mbDst.Add(m.MailboxCounts())
763 changes = append(changes, m.ChangeAddUID())
764 retrain = append(retrain, m)
766 for _, kw := range m.Keywords {
767 keywords[kw] = struct{}{}
771 err = tx.Update(&mbSrc)
772 xcheckf(ctx, err, "updating source mailbox counts")
774 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
776 // Ensure destination mailbox has keywords of the moved messages.
778 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
780 changes = append(changes, mbDst.ChangeKeywords())
783 err = tx.Update(&mbDst)
784 xcheckf(ctx, err, "updating mailbox with uidnext")
786 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
787 xcheckf(ctx, err, "retraining messages after move")
790 // Ensure UIDs of the removed message are in increasing order. It is quite common
791 // for all messages to be from a single source mailbox, meaning this is just one
792 // change, for which we preallocated space.
793 for _, ch := range removeChanges {
794 sort.Slice(ch.UIDs, func(i, j int) bool {
795 return ch.UIDs[i] < ch.UIDs[j]
797 changes = append(changes, ch)
799 store.BroadcastChanges(acc, changes)
803// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
804func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
805 log := xlog.WithContext(ctx)
806 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
807 acc, err := store.OpenAccount(reqInfo.AccountName)
808 xcheckf(ctx, err, "open account")
811 log.Check(err, "closing account")
814 if len(messageIDs) == 0 {
818 acc.WithWLock(func() {
819 removeChanges := map[int64]store.ChangeRemoveUIDs{}
820 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
822 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
823 var modseq store.ModSeq
825 remove := make([]store.Message, 0, len(messageIDs))
827 for _, mid := range messageIDs {
828 m := xmessageID(ctx, tx, mid)
830 if m.MailboxID != mb.ID {
832 err := tx.Update(&mb)
833 xcheckf(ctx, err, "updating mailbox counts")
834 changes = append(changes, mb.ChangeCounts())
836 mb = xmailboxID(ctx, tx, m.MailboxID)
839 qmr := bstore.QueryTx[store.Recipient](tx)
840 qmr.FilterEqual("MessageID", m.ID)
841 _, err = qmr.Delete()
842 xcheckf(ctx, err, "removing message recipients")
844 mb.Sub(m.MailboxCounts())
847 modseq, err = acc.NextModSeq(tx)
848 xcheckf(ctx, err, "assigning next modseq")
853 xcheckf(ctx, err, "marking message as expunged")
855 ch := removeChanges[m.MailboxID]
856 ch.UIDs = append(ch.UIDs, m.UID)
857 ch.MailboxID = m.MailboxID
859 removeChanges[m.MailboxID] = ch
860 remove = append(remove, m)
864 err := tx.Update(&mb)
865 xcheckf(ctx, err, "updating count in mailbox")
866 changes = append(changes, mb.ChangeCounts())
869 // Mark removed messages as not needing training, then retrain them, so if they
870 // were trained, they get untrained.
871 for i := range remove {
872 remove[i].Junk = false
873 remove[i].Notjunk = false
875 err = acc.RetrainMessages(ctx, log, tx, remove, true)
876 xcheckf(ctx, err, "untraining deleted messages")
879 for _, ch := range removeChanges {
880 sort.Slice(ch.UIDs, func(i, j int) bool {
881 return ch.UIDs[i] < ch.UIDs[j]
883 changes = append(changes, ch)
885 store.BroadcastChanges(acc, changes)
888 for _, mID := range messageIDs {
889 p := acc.MessagePath(mID)
891 log.Check(err, "removing message file for expunge")
895// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
896// flags should be lower-case, but will be converted and verified.
897func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
898 log := xlog.WithContext(ctx)
899 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
900 acc, err := store.OpenAccount(reqInfo.AccountName)
901 xcheckf(ctx, err, "open account")
904 log.Check(err, "closing account")
907 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
908 xcheckuserf(ctx, err, "parsing flags")
910 acc.WithRLock(func() {
911 var changes []store.Change
913 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
914 var modseq store.ModSeq
915 var retrain []store.Message
916 var mb, origmb store.Mailbox
918 for _, mid := range messageIDs {
919 m := xmessageID(ctx, tx, mid)
921 if mb.ID != m.MailboxID {
923 err := tx.Update(&mb)
924 xcheckf(ctx, err, "updating mailbox")
925 if mb.MailboxCounts != origmb.MailboxCounts {
926 changes = append(changes, mb.ChangeCounts())
928 if mb.KeywordsChanged(origmb) {
929 changes = append(changes, mb.ChangeKeywords())
932 mb = xmailboxID(ctx, tx, m.MailboxID)
935 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
937 mb.Sub(m.MailboxCounts())
939 m.Flags = m.Flags.Set(flags, flags)
941 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
942 mb.Add(m.MailboxCounts())
944 if m.Flags == oflags && !kwChanged {
949 modseq, err = acc.NextModSeq(tx)
950 xcheckf(ctx, err, "assigning next modseq")
954 xcheckf(ctx, err, "updating message")
956 changes = append(changes, m.ChangeFlags(oflags))
957 retrain = append(retrain, m)
961 err := tx.Update(&mb)
962 xcheckf(ctx, err, "updating mailbox")
963 if mb.MailboxCounts != origmb.MailboxCounts {
964 changes = append(changes, mb.ChangeCounts())
966 if mb.KeywordsChanged(origmb) {
967 changes = append(changes, mb.ChangeKeywords())
971 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
972 xcheckf(ctx, err, "retraining messages")
975 store.BroadcastChanges(acc, changes)
979// FlagsClear clears flags, either system flags like \Seen or custom keywords.
980func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
981 log := xlog.WithContext(ctx)
982 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
983 acc, err := store.OpenAccount(reqInfo.AccountName)
984 xcheckf(ctx, err, "open account")
987 log.Check(err, "closing account")
990 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
991 xcheckuserf(ctx, err, "parsing flags")
993 acc.WithRLock(func() {
994 var retrain []store.Message
995 var changes []store.Change
997 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
998 var modseq store.ModSeq
999 var mb, origmb store.Mailbox
1001 for _, mid := range messageIDs {
1002 m := xmessageID(ctx, tx, mid)
1004 if mb.ID != m.MailboxID {
1006 err := tx.Update(&mb)
1007 xcheckf(ctx, err, "updating counts for mailbox")
1008 if mb.MailboxCounts != origmb.MailboxCounts {
1009 changes = append(changes, mb.ChangeCounts())
1011 // note: cannot remove keywords from mailbox by removing keywords from message.
1013 mb = xmailboxID(ctx, tx, m.MailboxID)
1018 mb.Sub(m.MailboxCounts())
1019 m.Flags = m.Flags.Set(flags, store.Flags{})
1021 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
1022 mb.Add(m.MailboxCounts())
1024 if m.Flags == oflags && !changed {
1029 modseq, err = acc.NextModSeq(tx)
1030 xcheckf(ctx, err, "assigning next modseq")
1034 xcheckf(ctx, err, "updating message")
1036 changes = append(changes, m.ChangeFlags(oflags))
1037 retrain = append(retrain, m)
1041 err := tx.Update(&mb)
1042 xcheckf(ctx, err, "updating keywords in mailbox")
1043 if mb.MailboxCounts != origmb.MailboxCounts {
1044 changes = append(changes, mb.ChangeCounts())
1046 // note: cannot remove keywords from mailbox by removing keywords from message.
1049 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1050 xcheckf(ctx, err, "retraining messages")
1053 store.BroadcastChanges(acc, changes)
1057// MailboxCreate creates a new mailbox.
1058func (Webmail) MailboxCreate(ctx context.Context, name string) {
1059 log := xlog.WithContext(ctx)
1060 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1061 acc, err := store.OpenAccount(reqInfo.AccountName)
1062 xcheckf(ctx, err, "open account")
1065 log.Check(err, "closing account")
1068 name, _, err = store.CheckMailboxName(name, false)
1069 xcheckuserf(ctx, err, "checking mailbox name")
1071 acc.WithWLock(func() {
1072 var changes []store.Change
1073 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1076 changes, _, exists, err = acc.MailboxCreate(tx, name)
1078 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1080 xcheckf(ctx, err, "creating mailbox")
1083 store.BroadcastChanges(acc, changes)
1087// MailboxDelete deletes a mailbox and all its messages.
1088func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1089 log := xlog.WithContext(ctx)
1090 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1091 acc, err := store.OpenAccount(reqInfo.AccountName)
1092 xcheckf(ctx, err, "open account")
1095 log.Check(err, "closing account")
1098 // Messages to remove after having broadcasted the removal of messages.
1099 var removeMessageIDs []int64
1101 acc.WithWLock(func() {
1102 var changes []store.Change
1104 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1105 mb := xmailboxID(ctx, tx, mailboxID)
1106 if mb.Name == "Inbox" {
1107 // Inbox is special in IMAP and cannot be removed.
1108 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1111 var hasChildren bool
1113 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1115 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1117 xcheckf(ctx, err, "deleting mailbox")
1120 store.BroadcastChanges(acc, changes)
1123 for _, mID := range removeMessageIDs {
1124 p := acc.MessagePath(mID)
1126 log.Check(err, "removing message file for mailbox delete", mlog.Field("path", p))
1130// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1131// its child mailboxes.
1132func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1133 log := xlog.WithContext(ctx)
1134 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1135 acc, err := store.OpenAccount(reqInfo.AccountName)
1136 xcheckf(ctx, err, "open account")
1139 log.Check(err, "closing account")
1142 var expunged []store.Message
1144 acc.WithWLock(func() {
1145 var changes []store.Change
1147 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1148 mb := xmailboxID(ctx, tx, mailboxID)
1150 modseq, err := acc.NextModSeq(tx)
1151 xcheckf(ctx, err, "next modseq")
1153 // Mark messages as expunged.
1154 qm := bstore.QueryTx[store.Message](tx)
1155 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1156 qm.FilterEqual("Expunged", false)
1158 qm.Gather(&expunged)
1159 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1160 xcheckf(ctx, err, "deleting messages")
1162 // Remove Recipients.
1163 anyIDs := make([]any, len(expunged))
1164 for i, m := range expunged {
1167 qmr := bstore.QueryTx[store.Recipient](tx)
1168 qmr.FilterEqual("MessageID", anyIDs...)
1169 _, err = qmr.Delete()
1170 xcheckf(ctx, err, "removing message recipients")
1172 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1173 uids := make([]store.UID, len(expunged))
1174 for i, m := range expunged {
1175 m.Expunged = false // Gather returns updated values.
1176 mb.Sub(m.MailboxCounts())
1179 expunged[i].Junk = false
1180 expunged[i].Notjunk = false
1183 err = tx.Update(&mb)
1184 xcheckf(ctx, err, "updating mailbox for counts")
1186 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1187 xcheckf(ctx, err, "retraining expunged messages")
1189 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1190 changes = []store.Change{chremove, mb.ChangeCounts()}
1193 store.BroadcastChanges(acc, changes)
1196 for _, m := range expunged {
1197 p := acc.MessagePath(m.ID)
1199 log.Check(err, "removing message file after emptying mailbox", mlog.Field("path", p))
1203// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1204// ID and its messages are unchanged.
1205func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1206 log := xlog.WithContext(ctx)
1207 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1208 acc, err := store.OpenAccount(reqInfo.AccountName)
1209 xcheckf(ctx, err, "open account")
1212 log.Check(err, "closing account")
1215 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1216 // standard. We can just say no.
1217 newName, _, err = store.CheckMailboxName(newName, false)
1218 xcheckuserf(ctx, err, "checking new mailbox name")
1220 acc.WithWLock(func() {
1221 var changes []store.Change
1223 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1224 mbsrc := xmailboxID(ctx, tx, mailboxID)
1226 var isInbox, notExists, alreadyExists bool
1227 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1228 if isInbox || notExists || alreadyExists {
1229 xcheckuserf(ctx, err, "renaming mailbox")
1231 xcheckf(ctx, err, "renaming mailbox")
1234 store.BroadcastChanges(acc, changes)
1238// CompleteRecipient returns autocomplete matches for a recipient, returning the
1239// matches, most recently used first, and whether this is the full list and further
1240// requests for longer prefixes aren't necessary.
1241func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1242 log := xlog.WithContext(ctx)
1243 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1244 acc, err := store.OpenAccount(reqInfo.AccountName)
1245 xcheckf(ctx, err, "open account")
1248 log.Check(err, "closing account")
1251 search = strings.ToLower(search)
1253 var matches []string
1255 acc.WithRLock(func() {
1256 xdbread(ctx, acc, func(tx *bstore.Tx) {
1258 localpart smtp.Localpart
1261 seen := map[key]bool{}
1263 q := bstore.QueryTx[store.Recipient](tx)
1265 err := q.ForEach(func(r store.Recipient) error {
1266 k := key{r.Localpart, r.Domain}
1270 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1271 address := fmt.Sprintf("<%s@%s>", r.Localpart.String(), r.Domain)
1272 if !strings.Contains(strings.ToLower(address), search) {
1275 if len(matches) >= 20 {
1277 return bstore.StopForEach
1280 // Look in the message that was sent for a name along with the address.
1281 m := store.Message{ID: r.MessageID}
1283 xcheckf(ctx, err, "get sent message")
1284 if !m.Expunged && m.ParsedBuf != nil {
1285 var part message.Part
1286 err := json.Unmarshal(m.ParsedBuf, &part)
1287 xcheckf(ctx, err, "parsing part")
1289 dom, err := dns.ParseDomain(r.Domain)
1290 xcheckf(ctx, err, "parsing domain of recipient")
1293 lp := r.Localpart.String()
1294 checkAddrs := func(l []message.Address) {
1298 for _, a := range l {
1299 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1301 address = addressString(a, false)
1306 if part.Envelope != nil {
1307 env := part.Envelope
1314 matches = append(matches, address)
1318 xcheckf(ctx, err, "listing recipients")
1324// addressString returns an address into a string as it could be used in a message header.
1325func addressString(a message.Address, smtputf8 bool) string {
1327 dom, err := dns.ParseDomain(a.Host)
1329 if smtputf8 && dom.Unicode != "" {
1335 s := "<" + a.User + "@" + host + ">"
1337 // todo: properly encoded/escaped name
1338 s = a.Name + " " + s
1343// MailboxSetSpecialUse sets the special use flags of a mailbox.
1344func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1345 log := xlog.WithContext(ctx)
1346 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1347 acc, err := store.OpenAccount(reqInfo.AccountName)
1348 xcheckf(ctx, err, "open account")
1351 log.Check(err, "closing account")
1354 acc.WithWLock(func() {
1355 var changes []store.Change
1357 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1358 xmb := xmailboxID(ctx, tx, mb.ID)
1360 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1361 // we set, we clear it for the mailbox(es) that had it, if any.
1362 clearPrevious := func(clear bool, specialUse string) {
1366 var ombl []store.Mailbox
1367 q := bstore.QueryTx[store.Mailbox](tx)
1368 q.FilterNotEqual("ID", mb.ID)
1369 q.FilterEqual(specialUse, true)
1371 _, err := q.UpdateField(specialUse, false)
1372 xcheckf(ctx, err, "updating previous special-use mailboxes")
1374 for _, omb := range ombl {
1375 changes = append(changes, omb.ChangeSpecialUse())
1378 clearPrevious(mb.Archive, "Archive")
1379 clearPrevious(mb.Draft, "Draft")
1380 clearPrevious(mb.Junk, "Junk")
1381 clearPrevious(mb.Sent, "Sent")
1382 clearPrevious(mb.Trash, "Trash")
1384 xmb.SpecialUse = mb.SpecialUse
1385 err = tx.Update(&xmb)
1386 xcheckf(ctx, err, "updating special-use flags for mailbox")
1387 changes = append(changes, xmb.ChangeSpecialUse())
1390 store.BroadcastChanges(acc, changes)
1394// ThreadCollapse saves the ThreadCollapse field for the messages and its
1395// children. The messageIDs are typically thread roots. But not all roots
1396// (without parent) of a thread need to have the same collapsed state.
1397func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1398 log := xlog.WithContext(ctx)
1399 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1400 acc, err := store.OpenAccount(reqInfo.AccountName)
1401 xcheckf(ctx, err, "open account")
1404 log.Check(err, "closing account")
1407 if len(messageIDs) == 0 {
1408 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1411 acc.WithWLock(func() {
1412 changes := make([]store.Change, 0, len(messageIDs))
1413 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1414 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1415 // (child) messages. Further refined in FilterFn.
1416 threadIDs := map[int64]struct{}{}
1417 msgIDs := map[int64]struct{}{}
1418 for _, id := range messageIDs {
1419 m := store.Message{ID: id}
1421 if err == bstore.ErrAbsent {
1422 xcheckuserf(ctx, err, "get message")
1424 xcheckf(ctx, err, "get message")
1425 threadIDs[m.ThreadID] = struct{}{}
1426 msgIDs[id] = struct{}{}
1429 var updated []store.Message
1430 q := bstore.QueryTx[store.Message](tx)
1431 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1432 q.FilterNotEqual("ThreadCollapsed", collapse)
1433 q.FilterFn(func(tm store.Message) bool {
1434 for _, id := range tm.ThreadParentIDs {
1435 if _, ok := msgIDs[id]; ok {
1439 _, ok := msgIDs[tm.ID]
1443 q.SortAsc("ID") // Consistent order for testing.
1444 _, err = q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1445 xcheckf(ctx, err, "updating collapse in database")
1447 for _, m := range updated {
1448 changes = append(changes, m.ChangeThread())
1451 store.BroadcastChanges(acc, changes)
1455// ThreadMute saves the ThreadMute field for the messages and their children.
1456// If messages are muted, they are also marked collapsed.
1457func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1458 log := xlog.WithContext(ctx)
1459 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1460 acc, err := store.OpenAccount(reqInfo.AccountName)
1461 xcheckf(ctx, err, "open account")
1464 log.Check(err, "closing account")
1467 if len(messageIDs) == 0 {
1468 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1471 acc.WithWLock(func() {
1472 changes := make([]store.Change, 0, len(messageIDs))
1473 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1474 threadIDs := map[int64]struct{}{}
1475 msgIDs := map[int64]struct{}{}
1476 for _, id := range messageIDs {
1477 m := store.Message{ID: id}
1479 if err == bstore.ErrAbsent {
1480 xcheckuserf(ctx, err, "get message")
1482 xcheckf(ctx, err, "get message")
1483 threadIDs[m.ThreadID] = struct{}{}
1484 msgIDs[id] = struct{}{}
1487 var updated []store.Message
1489 q := bstore.QueryTx[store.Message](tx)
1490 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1491 q.FilterFn(func(tm store.Message) bool {
1492 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1495 for _, id := range tm.ThreadParentIDs {
1496 if _, ok := msgIDs[id]; ok {
1500 _, ok := msgIDs[tm.ID]
1504 fields := map[string]any{"ThreadMuted": mute}
1506 fields["ThreadCollapsed"] = true
1508 _, err = q.UpdateFields(fields)
1509 xcheckf(ctx, err, "updating mute in database")
1511 for _, m := range updated {
1512 changes = append(changes, m.ChangeThread())
1515 store.BroadcastChanges(acc, changes)
1519// SecurityResult indicates whether a security feature is supported.
1520type SecurityResult string
1523 SecurityResultError SecurityResult = "error"
1524 SecurityResultNo SecurityResult = "no"
1525 SecurityResultYes SecurityResult = "yes"
1526 // Unknown whether supported. Finding out may only be (reasonably) possible when
1527 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1529 SecurityResultUnknown SecurityResult = "unknown"
1532// RecipientSecurity is a quick analysis of the security properties of delivery to
1533// the recipient (domain).
1534type RecipientSecurity struct {
1535 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1536 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1538 STARTTLS SecurityResult
1540 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1542 MTASTS SecurityResult
1544 // Whether MX lookup response was DNSSEC-signed.
1545 DNSSEC SecurityResult
1547 // Whether first delivery destination has DANE records.
1550 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1551 // Will be "unknown" if no delivery to the domain has been attempted yet.
1552 RequireTLS SecurityResult
1555// RecipientSecurity looks up security properties of the address in the
1556// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1557func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1558 resolver := dns.StrictResolver{Pkg: "webmail"}
1559 return recipientSecurity(ctx, resolver, messageAddressee)
1562// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1563func logPanic(ctx context.Context) {
1568 log := xlog.WithContext(ctx)
1569 log.Error("recover from panic", mlog.Field("panic", x))
1571 metrics.PanicInc(metrics.Webmail)
1574// separate function for testing with mocked resolver.
1575func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1576 log := xlog.WithContext(ctx)
1578 rs := RecipientSecurity{
1579 SecurityResultUnknown,
1580 SecurityResultUnknown,
1581 SecurityResultUnknown,
1582 SecurityResultUnknown,
1583 SecurityResultUnknown,
1586 msgAddr, err := mail.ParseAddress(messageAddressee)
1588 return rs, fmt.Errorf("parsing message addressee: %v", err)
1591 addr, err := smtp.ParseAddress(msgAddr.Address)
1593 return rs, fmt.Errorf("parsing address: %v", err)
1596 var wg sync.WaitGroup
1604 policy, _, _, err := mtastsdb.Get(ctx, resolver, addr.Domain)
1605 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1606 rs.MTASTS = SecurityResultYes
1607 } else if err == nil {
1608 rs.MTASTS = SecurityResultNo
1610 rs.MTASTS = SecurityResultError
1620 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log, resolver, dns.IPDomain{Domain: addr.Domain})
1622 rs.DNSSEC = SecurityResultError
1625 if origNextHopAuthentic && expandedNextHopAuthentic {
1626 rs.DNSSEC = SecurityResultYes
1628 rs.DNSSEC = SecurityResultNo
1631 if !origNextHopAuthentic {
1632 rs.DANE = SecurityResultNo
1636 // We're only looking at the first host to deliver to (typically first mx destination).
1637 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1638 return // Should not happen.
1642 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1643 // error result instead of no-DANE result.
1644 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log, resolver, host, map[string][]net.IP{})
1646 rs.DANE = SecurityResultError
1650 rs.DANE = SecurityResultNo
1654 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log, resolver, host.Domain, expandedAuthentic, expandedHost)
1656 rs.DANE = SecurityResultError
1658 } else if daneRequired {
1659 rs.DANE = SecurityResultYes
1661 rs.DANE = SecurityResultNo
1665 // STARTTLS and RequireTLS
1666 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1667 acc, err := store.OpenAccount(reqInfo.AccountName)
1668 xcheckf(ctx, err, "open account")
1672 log.Check(err, "closing account")
1676 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1677 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1678 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1680 if err == bstore.ErrAbsent {
1682 } else if err != nil {
1683 rs.STARTTLS = SecurityResultError
1684 rs.RequireTLS = SecurityResultError
1685 log.Errorx("looking up recipient domain", err, mlog.Field("domain", addr.Domain))
1689 rs.STARTTLS = SecurityResultYes
1691 rs.STARTTLS = SecurityResultNo
1694 rs.RequireTLS = SecurityResultYes
1696 rs.RequireTLS = SecurityResultNo
1700 xcheckf(ctx, err, "lookup recipient domain")
1702 // Close account as soon as possible, not after waiting for MTA-STS/DNSSEC/DANE
1703 // checks to complete, which can take a while.
1705 log.Check(err, "closing account")
1713func slicesAny[T any](l []T) []any {
1714 r := make([]any, len(l))
1715 for i, v := range l {
1721// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
1722func (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) {