5 cryptorand "crypto/rand"
29 "golang.org/x/exp/maps"
31 "github.com/mjl-/bstore"
32 "github.com/mjl-/sherpa"
33 "github.com/mjl-/sherpadoc"
34 "github.com/mjl-/sherpaprom"
36 "github.com/mjl-/mox/config"
37 "github.com/mjl-/mox/dkim"
38 "github.com/mjl-/mox/dns"
39 "github.com/mjl-/mox/message"
40 "github.com/mjl-/mox/metrics"
41 "github.com/mjl-/mox/mlog"
42 "github.com/mjl-/mox/mox-"
43 "github.com/mjl-/mox/moxio"
44 "github.com/mjl-/mox/moxvar"
45 "github.com/mjl-/mox/mtasts"
46 "github.com/mjl-/mox/mtastsdb"
47 "github.com/mjl-/mox/queue"
48 "github.com/mjl-/mox/smtp"
49 "github.com/mjl-/mox/smtpclient"
50 "github.com/mjl-/mox/store"
51 "github.com/mjl-/mox/webauth"
52 "github.com/mjl-/mox/webops"
56var webmailapiJSON []byte
59 maxMessageSize int64 // From listener.
60 cookiePath string // From listener.
61 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
64func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
65 err := json.Unmarshal(buf, &doc)
67 pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
72var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
74var sherpaHandlerOpts *sherpa.HandlerOpts
76func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
77 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
81 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
83 pkglog.Fatalx("creating sherpa prometheus collector", err)
86 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
88 _, err = makeSherpaHandler(0, "", false)
90 pkglog.Fatalx("sherpa handler", err)
94// LoginPrep returns a login token, and also sets it as cookie. Both must be
95// present in the call to Login.
96func (w Webmail) LoginPrep(ctx context.Context) string {
97 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
101 _, err := cryptorand.Read(data[:])
102 xcheckf(ctx, err, "generate token")
103 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
105 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
110// Login returns a session token for the credentials, or fails with error code
111// "user:badLogin". Call LoginPrep to get a loginToken.
112func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
113 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
116 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
117 if _, ok := err.(*sherpa.Error); ok {
120 xcheckf(ctx, err, "login")
124// Logout invalidates the session token.
125func (w Webmail) Logout(ctx context.Context) {
126 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
129 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.Account.Name, reqInfo.SessionToken)
130 xcheckf(ctx, err, "logout")
133// Token returns a single-use token to use for an SSE connection. A token can only
134// be used for a single SSE connection. Tokens are stored in memory for a maximum
135// of 1 minute, with at most 10 unused tokens (the most recently created) per
137func (Webmail) Token(ctx context.Context) string {
138 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
139 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
142// Requests sends a new request for an open SSE connection. Any currently active
143// request for the connection will be canceled, but this is done asynchrously, so
144// the SSE connection may still send results for the previous request. Callers
145// should take care to ignore such results. If req.Cancel is set, no new request is
147func (Webmail) Request(ctx context.Context, req Request) {
148 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
150 if !req.Cancel && req.Page.Count <= 0 {
151 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
154 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
156 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
161// ParsedMessage returns enough to render the textual body of a message. It is
162// assumed the client already has other fields through MessageItem.
163func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
164 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
166 acc := reqInfo.Account
168 xdbread(ctx, acc, func(tx *bstore.Tx) {
169 m := xmessageID(ctx, tx, msgID)
171 state := msgState{acc: acc}
174 pm, err = parsedMessage(log, m, &state, true, false)
175 xcheckf(ctx, err, "parsing message")
177 if len(pm.envelope.From) == 1 {
178 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
179 xcheckf(ctx, err, "looking up view mode for from address")
185// fromAddrViewMode returns the view mode for a from address.
186func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
187 settingsViewMode := func() (store.ViewMode, error) {
188 settings := store.Settings{ID: 1}
189 if err := tx.Get(&settings); err != nil {
190 return store.ModeText, err
192 if settings.ShowHTML {
193 return store.ModeHTML, nil
195 return store.ModeText, nil
198 lp, err := smtp.ParseLocalpart(from.User)
200 return settingsViewMode()
202 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
203 fas := store.FromAddressSettings{FromAddress: fromAddr}
205 if err == bstore.ErrAbsent {
206 return settingsViewMode()
208 return fas.ViewMode, err
211// FromAddressSettingsSave saves per-"From"-address settings.
212func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
213 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
214 acc := reqInfo.Account
216 if fas.FromAddress == "" {
217 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
220 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
221 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
222 err := tx.Update(&fas)
223 xcheckf(ctx, err, "updating settings for from address")
225 err := tx.Insert(&fas)
226 xcheckf(ctx, err, "inserting settings for from address")
231// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
232// of the message in storage. Used when opening a previously saved draft message
234// If no message is find, zero is returned, not an error.
235func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
236 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
237 acc := reqInfo.Account
239 messageID, _, _ = message.MessageIDCanonical(messageID)
241 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
244 xdbread(ctx, acc, func(tx *bstore.Tx) {
245 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
246 if err == bstore.ErrAbsent {
249 xcheckf(ctx, err, "looking up message by message-id")
255// ComposeMessage is a message to be composed, for saving draft messages.
256type ComposeMessage struct {
261 ReplyTo string // If non-empty, Reply-To header to add to message.
264 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
265 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
268// MessageCompose composes a message and saves it to the mailbox. Used for
269// saving draft messages.
270func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
271 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
272 acc := reqInfo.Account
275 log.Debug("message compose")
277 // Prevent any accidental control characters, or attempts at getting bare \r or \n
279 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
280 for _, s := range l {
281 for _, c := range s {
283 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
289 fromAddr, err := parseAddress(m.From)
290 xcheckuserf(ctx, err, "parsing From address")
292 var replyTo *message.NameAddress
294 addr, err := parseAddress(m.ReplyTo)
295 xcheckuserf(ctx, err, "parsing Reply-To address")
299 var recipients []smtp.Address
301 var toAddrs []message.NameAddress
302 for _, s := range m.To {
303 addr, err := parseAddress(s)
304 xcheckuserf(ctx, err, "parsing To address")
305 toAddrs = append(toAddrs, addr)
306 recipients = append(recipients, addr.Address)
309 var ccAddrs []message.NameAddress
310 for _, s := range m.Cc {
311 addr, err := parseAddress(s)
312 xcheckuserf(ctx, err, "parsing Cc address")
313 ccAddrs = append(ccAddrs, addr)
314 recipients = append(recipients, addr.Address)
317 var bccAddrs []message.NameAddress
318 for _, s := range m.Bcc {
319 addr, err := parseAddress(s)
320 xcheckuserf(ctx, err, "parsing Bcc address")
321 bccAddrs = append(bccAddrs, addr)
322 recipients = append(recipients, addr.Address)
325 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
327 for _, a := range recipients {
328 if a.Localpart.IsInternational() {
333 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
334 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
337 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
341 // Create file to compose message into.
342 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
343 xcheckf(ctx, err, "creating temporary file for compose message")
344 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
346 // If writing to the message file fails, we abort immediately.
347 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
353 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
354 xcheckuserf(ctx, err, "making message")
355 } else if ok && errors.Is(err, message.ErrCompose) {
356 xcheckf(ctx, err, "making message")
361 // Outer message headers.
362 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
364 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
366 xc.HeaderAddrs("To", toAddrs)
367 xc.HeaderAddrs("Cc", ccAddrs)
368 xc.HeaderAddrs("Bcc", bccAddrs)
370 xc.Subject(m.Subject)
373 // Add In-Reply-To and References headers.
374 if m.ResponseMessageID > 0 {
375 xdbread(ctx, acc, func(tx *bstore.Tx) {
376 rm := xmessageID(ctx, tx, m.ResponseMessageID)
377 msgr := acc.MessageReader(rm)
380 log.Check(err, "closing message reader")
382 rp, err := rm.LoadPart(msgr)
383 xcheckf(ctx, err, "load parsed message")
384 h, err := rp.Header()
385 xcheckf(ctx, err, "parsing header")
387 if rp.Envelope == nil {
391 if rp.Envelope.MessageID != "" {
392 xc.Header("In-Reply-To", rp.Envelope.MessageID)
394 refs := h.Values("References")
395 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
396 refs = []string{rp.Envelope.InReplyTo}
398 if rp.Envelope.MessageID != "" {
399 refs = append(refs, rp.Envelope.MessageID)
402 xc.Header("References", strings.Join(refs, "\r\n\t"))
406 xc.Header("MIME-Version", "1.0")
407 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
408 xc.Header("Content-Type", ct)
409 xc.Header("Content-Transfer-Encoding", cte)
411 xc.Write([]byte(textBody))
416 // Remove previous draft message, append message to destination mailbox.
417 acc.WithRLock(func() {
418 var changes []store.Change
420 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
421 var modseq store.ModSeq // Only set if needed.
423 if m.DraftMessageID > 0 {
424 var nchanges []store.Change
425 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
426 changes = append(changes, nchanges...)
427 // On-disk file is removed after lock.
430 // Find mailbox to write to.
431 mb := store.Mailbox{ID: mailboxID}
433 if err == bstore.ErrAbsent {
434 xcheckuserf(ctx, err, "looking up mailbox")
436 xcheckf(ctx, err, "looking up mailbox")
439 modseq, err = acc.NextModSeq(tx)
440 xcheckf(ctx, err, "next modseq")
447 MailboxOrigID: mb.ID,
448 Flags: store.Flags{Notjunk: true},
452 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
453 xcheckf(ctx, err, "checking quota")
455 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
458 // Update mailbox before delivery, which changes uidnext.
459 mb.Add(nm.MailboxCounts())
461 xcheckf(ctx, err, "updating sent mailbox for counts")
463 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
464 xcheckf(ctx, err, "storing message in mailbox")
466 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
469 store.BroadcastChanges(acc, changes)
472 // Remove on-disk file for removed draft message.
473 if m.DraftMessageID > 0 {
474 p := acc.MessagePath(m.DraftMessageID)
476 log.Check(err, "removing draft message file")
482// Attachment is a MIME part is an existing message that is not intended as
483// viewable text or HTML part.
484type Attachment struct {
485 Path []int // Indices into top-level message.Part.Parts.
487 // File name based on "name" attribute of "Content-Type", or the "filename"
488 // attribute of "Content-Disposition".
494// SubmitMessage is an email message to be sent to one or more recipients.
495// Addresses are formatted as just email address, or with a name like "name
497type SubmitMessage struct {
502 ReplyTo string // If non-empty, Reply-To header to add to message.
506 ForwardAttachments ForwardAttachments
508 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
509 UserAgent string // User-Agent header added if not empty.
510 RequireTLS *bool // For "Require TLS" extension during delivery.
511 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
512 ArchiveThread bool // If set, thread is archived after sending message.
513 DraftMessageID int64 // If set, draft message that will be removed after sending.
516// ForwardAttachments references attachments by a list of message.Part paths.
517type ForwardAttachments struct {
518 MessageID int64 // Only relevant if MessageID is not 0.
519 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
522// File is a new attachment (not from an existing message that is being
523// forwarded) to send with a SubmitMessage.
526 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
529// parseAddress expects either a plain email address like "user@domain", or a
530// single address as used in a message header, like "name <user@domain>".
531func parseAddress(msghdr string) (message.NameAddress, error) {
533 parser := mail.AddressParser{WordDecoder: &wordDecoder}
534 a, err := parser.Parse(msghdr)
536 return message.NameAddress{}, err
539 path, err := smtp.ParseNetMailAddress(a.Address)
541 return message.NameAddress{}, err
543 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
546func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
548 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
550 mb := store.Mailbox{ID: mailboxID}
552 if err == bstore.ErrAbsent {
553 xcheckuserf(ctx, err, "getting mailbox")
555 xcheckf(ctx, err, "getting mailbox")
559// xmessageID returns a non-expunged message or panics with a sherpa error.
560func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
562 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
564 m := store.Message{ID: messageID}
566 if err == bstore.ErrAbsent {
567 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
568 } else if err == nil && m.Expunged {
569 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
571 xcheckf(ctx, err, "getting message")
575func xrandomID(ctx context.Context, n int) string {
576 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
579func xrandom(ctx context.Context, n int) []byte {
580 buf := make([]byte, n)
581 x, err := cryptorand.Read(buf)
582 xcheckf(ctx, err, "read random")
584 xcheckf(ctx, errors.New("short random read"), "read random")
589// MessageSubmit sends a message by submitting it the outgoing email queue. The
590// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
591// Bcc message header.
593// If a Sent mailbox is configured, messages are added to it after submitting
594// to the delivery queue. If Bcc addresses were present, a header is prepended
595// to the message stored in the Sent mailbox.
596func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
597 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
598 acc := reqInfo.Account
601 log.Debug("message submit")
603 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
605 // 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. also not unlike the webapi Submit method.
607 // Prevent any accidental control characters, or attempts at getting bare \r or \n
609 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
610 for _, s := range l {
611 for _, c := range s {
613 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
619 fromAddr, err := parseAddress(m.From)
620 xcheckuserf(ctx, err, "parsing From address")
622 var replyTo *message.NameAddress
624 a, err := parseAddress(m.ReplyTo)
625 xcheckuserf(ctx, err, "parsing Reply-To address")
629 var recipients []smtp.Address
631 var toAddrs []message.NameAddress
632 for _, s := range m.To {
633 addr, err := parseAddress(s)
634 xcheckuserf(ctx, err, "parsing To address")
635 toAddrs = append(toAddrs, addr)
636 recipients = append(recipients, addr.Address)
639 var ccAddrs []message.NameAddress
640 for _, s := range m.Cc {
641 addr, err := parseAddress(s)
642 xcheckuserf(ctx, err, "parsing Cc address")
643 ccAddrs = append(ccAddrs, addr)
644 recipients = append(recipients, addr.Address)
647 var bccAddrs []message.NameAddress
648 for _, s := range m.Bcc {
649 addr, err := parseAddress(s)
650 xcheckuserf(ctx, err, "parsing Bcc address")
651 bccAddrs = append(bccAddrs, addr)
652 recipients = append(recipients, addr.Address)
655 // Check if from address is allowed for account.
656 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
657 metricSubmission.WithLabelValues("badfrom").Inc()
658 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
661 if len(recipients) == 0 {
662 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
665 // Check outgoing message rate limit.
666 xdbread(ctx, acc, func(tx *bstore.Tx) {
667 rcpts := make([]smtp.Path, len(recipients))
668 for i, r := range recipients {
669 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
671 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
673 metricSubmission.WithLabelValues("messagelimiterror").Inc()
674 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
675 } else if rcptlimit >= 0 {
676 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
677 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
679 xcheckf(ctx, err, "checking send limit")
682 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
684 for _, a := range recipients {
685 if a.Localpart.IsInternational() {
690 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
691 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
694 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
698 // Create file to compose message into.
699 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
700 xcheckf(ctx, err, "creating temporary file for message")
701 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
703 // If writing to the message file fails, we abort immediately.
704 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
710 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
711 xcheckuserf(ctx, err, "making message")
712 } else if ok && errors.Is(err, message.ErrCompose) {
713 xcheckf(ctx, err, "making message")
718 // 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
720 // Each queued message gets a Received header.
721 // We don't have access to the local IP for adding.
722 // We cannot use VIA, because there is no registered method. We would like to use
723 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
724 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
725 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
726 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
727 recvHdrFor := func(rcptTo string) string {
728 recvHdr := &message.HeaderWriter{}
729 // For additional Received-header clauses, see:
730 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
731 // Note: we don't have "via" or "with", there is no registered for webmail.
732 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
733 if reqInfo.Request.TLS != nil {
734 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
736 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
737 return recvHdr.String()
740 // Outer message headers.
741 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
743 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
745 xc.HeaderAddrs("To", toAddrs)
746 xc.HeaderAddrs("Cc", ccAddrs)
747 // We prepend Bcc headers to the message when adding to the Sent mailbox.
749 xc.Subject(m.Subject)
752 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
753 xc.Header("Message-Id", messageID)
754 xc.Header("Date", time.Now().Format(message.RFC5322Z))
755 // Add In-Reply-To and References headers.
756 if m.ResponseMessageID > 0 {
757 xdbread(ctx, acc, func(tx *bstore.Tx) {
758 rm := xmessageID(ctx, tx, m.ResponseMessageID)
759 msgr := acc.MessageReader(rm)
762 log.Check(err, "closing message reader")
764 rp, err := rm.LoadPart(msgr)
765 xcheckf(ctx, err, "load parsed message")
766 h, err := rp.Header()
767 xcheckf(ctx, err, "parsing header")
769 if rp.Envelope == nil {
773 if rp.Envelope.MessageID != "" {
774 xc.Header("In-Reply-To", rp.Envelope.MessageID)
776 refs := h.Values("References")
777 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
778 refs = []string{rp.Envelope.InReplyTo}
780 if rp.Envelope.MessageID != "" {
781 refs = append(refs, rp.Envelope.MessageID)
784 xc.Header("References", strings.Join(refs, "\r\n\t"))
788 if m.UserAgent != "" {
789 xc.Header("User-Agent", m.UserAgent)
791 if m.RequireTLS != nil && !*m.RequireTLS {
792 xc.Header("TLS-Required", "No")
794 xc.Header("MIME-Version", "1.0")
796 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
797 mp := multipart.NewWriter(xc)
798 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
801 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
802 textHdr := textproto.MIMEHeader{}
803 textHdr.Set("Content-Type", ct)
804 textHdr.Set("Content-Transfer-Encoding", cte)
806 textp, err := mp.CreatePart(textHdr)
807 xcheckf(ctx, err, "adding text part to message")
808 _, err = textp.Write(textBody)
809 xcheckf(ctx, err, "writing text part")
811 xaddPart := func(ct, filename string) io.Writer {
812 ahdr := textproto.MIMEHeader{}
813 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
815 ahdr.Set("Content-Type", ct)
816 ahdr.Set("Content-Transfer-Encoding", "base64")
817 ahdr.Set("Content-Disposition", cd)
818 ap, err := mp.CreatePart(ahdr)
819 xcheckf(ctx, err, "adding attachment part to message")
823 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
824 ap := xaddPart(ct, filename)
826 for len(base64Data) > 0 {
832 line, base64Data = base64Data[:n], base64Data[n:]
833 _, err := ap.Write(line)
834 xcheckf(ctx, err, "writing attachment")
835 _, err = ap.Write([]byte("\r\n"))
836 xcheckf(ctx, err, "writing attachment")
840 xaddAttachment := func(ct, filename string, r io.Reader) {
841 ap := xaddPart(ct, filename)
842 wc := moxio.Base64Writer(ap)
843 _, err := io.Copy(wc, r)
844 xcheckf(ctx, err, "adding attachment")
846 xcheckf(ctx, err, "flushing attachment")
849 for _, a := range m.Attachments {
851 if !strings.HasPrefix(s, "data:") {
852 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
855 t := strings.SplitN(s, ",", 2)
857 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
859 if !strings.HasSuffix(t[0], "base64") {
860 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
862 ct := strings.TrimSuffix(t[0], "base64")
863 ct = strings.TrimSuffix(ct, ";")
865 ct = "application/octet-stream"
867 filename := a.Filename
869 filename = "unnamed.bin"
871 params := map[string]string{"name": filename}
872 ct = mime.FormatMediaType(ct, params)
874 // Ensure base64 is valid, then we'll write the original string.
875 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
876 xcheckuserf(ctx, err, "parsing attachment as base64")
878 xaddAttachmentBase64(ct, filename, []byte(t[1]))
881 if len(m.ForwardAttachments.Paths) > 0 {
882 acc.WithRLock(func() {
883 xdbread(ctx, acc, func(tx *bstore.Tx) {
884 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
885 msgr := acc.MessageReader(fm)
888 log.Check(err, "closing message reader")
891 fp, err := fm.LoadPart(msgr)
892 xcheckf(ctx, err, "load parsed message")
894 for _, path := range m.ForwardAttachments.Paths {
896 for _, xp := range path {
897 if xp < 0 || xp >= len(ap.Parts) {
898 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
903 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
905 filename = "unnamed.bin"
907 params := map[string]string{"name": filename}
908 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
909 params["charset"] = pcharset
911 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
912 ct = mime.FormatMediaType(ct, params)
913 xaddAttachment(ct, filename, ap.Reader())
920 xcheckf(ctx, err, "writing mime multipart")
922 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
923 xc.Header("Content-Type", ct)
924 xc.Header("Content-Transfer-Encoding", cte)
926 xc.Write([]byte(textBody))
931 // Add DKIM-Signature headers.
933 fd := fromAddr.Address.Domain
934 confDom, _ := mox.Conf.Domain(fd)
935 selectors := mox.DKIMSelectors(confDom.DKIM)
936 if len(selectors) > 0 {
937 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
939 metricServerErrors.WithLabelValues("dkimsign").Inc()
941 xcheckf(ctx, err, "sign dkim")
943 msgPrefix = dkimHeaders
946 accConf, _ := acc.Conf()
947 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
948 xcheckf(ctx, err, "parsing login address")
949 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
950 fromPath := fromAddr.Address.Path()
951 var localpartBase string
953 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
955 qml := make([]queue.Msg, len(recipients))
957 for i, rcpt := range recipients {
961 fromID = xrandomID(ctx, 16)
962 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
965 // Don't use per-recipient unique message prefix when multiple recipients are
966 // present, or the queue cannot deliver it in a single smtp transaction.
968 if len(recipients) == 1 {
969 recvRcpt = rcpt.Pack(smtputf8)
971 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
972 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
974 Localpart: rcpt.Localpart,
975 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
977 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
978 if m.FutureRelease != nil {
979 ival := time.Until(*m.FutureRelease)
981 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
982 } else if ival > queue.FutureReleaseIntervalMax {
983 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
985 qm.NextAttempt = *m.FutureRelease
986 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
987 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
990 // no qm.Extra from webmail
993 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
995 metricSubmission.WithLabelValues("queueerror").Inc()
997 xcheckf(ctx, err, "adding messages to the delivery queue")
998 metricSubmission.WithLabelValues("ok").Inc()
1000 var modseq store.ModSeq // Only set if needed.
1002 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1003 // remove any draft message.
1004 acc.WithRLock(func() {
1005 var changes []store.Change
1009 if x := recover(); x != nil {
1011 metricServerErrors.WithLabelValues("submit").Inc()
1016 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1017 if m.DraftMessageID > 0 {
1018 var nchanges []store.Change
1019 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1020 changes = append(changes, nchanges...)
1021 // On-disk file is removed after lock.
1024 if m.ResponseMessageID > 0 {
1025 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1032 if !rm.Junk && !rm.Notjunk {
1035 if rm.Flags != oflags {
1036 modseq, err = acc.NextModSeq(tx)
1037 xcheckf(ctx, err, "next modseq")
1039 err := tx.Update(&rm)
1040 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1041 changes = append(changes, rm.ChangeFlags(oflags))
1043 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1044 xcheckf(ctx, err, "retraining messages after reply/forward")
1047 // Move messages from this thread still in this mailbox to the designated Archive
1049 if m.ArchiveThread {
1050 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1051 if err == bstore.ErrAbsent {
1052 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1054 xcheckf(ctx, err, "looking up designated archive mailbox")
1057 q := bstore.QueryTx[store.Message](tx)
1058 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID})
1059 q.FilterEqual("Expunged", false)
1060 err = q.IDs(&msgIDs)
1061 xcheckf(ctx, err, "listing messages in thread to archive")
1062 if len(msgIDs) > 0 {
1063 var nchanges []store.Change
1064 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1065 changes = append(changes, nchanges...)
1070 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1071 if err == bstore.ErrAbsent {
1072 // There is no mailbox designated as Sent mailbox, so we're done.
1075 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1078 modseq, err = acc.NextModSeq(tx)
1079 xcheckf(ctx, err, "next modseq")
1082 // If there were bcc headers, prepend those to the stored message only, before the
1083 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1084 // message won't validate with DKIM anymore, which is fine.
1085 if len(bccAddrs) > 0 {
1086 var sb strings.Builder
1087 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1088 xbcc.HeaderAddrs("Bcc", bccAddrs)
1090 msgPrefix = sb.String() + msgPrefix
1093 sentm := store.Message{
1096 MailboxID: sentmb.ID,
1097 MailboxOrigID: sentmb.ID,
1098 Flags: store.Flags{Notjunk: true, Seen: true},
1099 Size: int64(len(msgPrefix)) + xc.Size,
1100 MsgPrefix: []byte(msgPrefix),
1103 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1104 xcheckf(ctx, err, "checking quota")
1106 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1109 // Update mailbox before delivery, which changes uidnext.
1110 sentmb.Add(sentm.MailboxCounts())
1111 err = tx.Update(&sentmb)
1112 xcheckf(ctx, err, "updating sent mailbox for counts")
1114 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1116 metricSubmission.WithLabelValues("storesenterror").Inc()
1119 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1121 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1124 store.BroadcastChanges(acc, changes)
1127 // Remove on-disk file for removed draft message.
1128 if m.DraftMessageID > 0 {
1129 p := acc.MessagePath(m.DraftMessageID)
1131 log.Check(err, "removing draft message file")
1135// MessageMove moves messages to another mailbox. If the message is already in
1136// the mailbox an error is returned.
1137func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1138 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1139 acc := reqInfo.Account
1142 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1145var xops = webops.XOps{
1148 Checkuserf: xcheckuserf,
1151// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1152func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1153 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1154 acc := reqInfo.Account
1157 if len(messageIDs) == 0 {
1161 xops.MessageDelete(ctx, log, acc, messageIDs)
1164// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1165// flags should be lower-case, but will be converted and verified.
1166func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1167 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1168 acc := reqInfo.Account
1171 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1174// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1175func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1176 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1177 acc := reqInfo.Account
1180 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1183// MailboxCreate creates a new mailbox.
1184func (Webmail) MailboxCreate(ctx context.Context, name string) {
1185 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1186 acc := reqInfo.Account
1189 name, _, err = store.CheckMailboxName(name, false)
1190 xcheckuserf(ctx, err, "checking mailbox name")
1192 acc.WithWLock(func() {
1193 var changes []store.Change
1194 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1197 changes, _, exists, err = acc.MailboxCreate(tx, name)
1199 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1201 xcheckf(ctx, err, "creating mailbox")
1204 store.BroadcastChanges(acc, changes)
1208// MailboxDelete deletes a mailbox and all its messages.
1209func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1210 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1211 acc := reqInfo.Account
1214 // Messages to remove after having broadcasted the removal of messages.
1215 var removeMessageIDs []int64
1217 acc.WithWLock(func() {
1218 var changes []store.Change
1220 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1221 mb := xmailboxID(ctx, tx, mailboxID)
1222 if mb.Name == "Inbox" {
1223 // Inbox is special in IMAP and cannot be removed.
1224 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1227 var hasChildren bool
1229 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1231 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1233 xcheckf(ctx, err, "deleting mailbox")
1236 store.BroadcastChanges(acc, changes)
1239 for _, mID := range removeMessageIDs {
1240 p := acc.MessagePath(mID)
1242 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1246// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1247// its child mailboxes.
1248func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1249 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1250 acc := reqInfo.Account
1253 var expunged []store.Message
1255 acc.WithWLock(func() {
1256 var changes []store.Change
1258 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1259 mb := xmailboxID(ctx, tx, mailboxID)
1261 modseq, err := acc.NextModSeq(tx)
1262 xcheckf(ctx, err, "next modseq")
1264 // Mark messages as expunged.
1265 qm := bstore.QueryTx[store.Message](tx)
1266 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1267 qm.FilterEqual("Expunged", false)
1269 qm.Gather(&expunged)
1270 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1271 xcheckf(ctx, err, "deleting messages")
1273 // Remove Recipients.
1274 anyIDs := make([]any, len(expunged))
1275 for i, m := range expunged {
1278 qmr := bstore.QueryTx[store.Recipient](tx)
1279 qmr.FilterEqual("MessageID", anyIDs...)
1280 _, err = qmr.Delete()
1281 xcheckf(ctx, err, "removing message recipients")
1283 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1285 uids := make([]store.UID, len(expunged))
1286 for i, m := range expunged {
1287 m.Expunged = false // Gather returns updated values.
1288 mb.Sub(m.MailboxCounts())
1292 expunged[i].Junk = false
1293 expunged[i].Notjunk = false
1296 err = tx.Update(&mb)
1297 xcheckf(ctx, err, "updating mailbox for counts")
1299 err = acc.AddMessageSize(log, tx, -totalSize)
1300 xcheckf(ctx, err, "updating disk usage")
1302 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1303 xcheckf(ctx, err, "retraining expunged messages")
1305 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1306 changes = []store.Change{chremove, mb.ChangeCounts()}
1309 store.BroadcastChanges(acc, changes)
1312 for _, m := range expunged {
1313 p := acc.MessagePath(m.ID)
1315 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1319// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1320// ID and its messages are unchanged.
1321func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1322 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1323 acc := reqInfo.Account
1325 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1326 // standard. We can just say no.
1328 newName, _, err = store.CheckMailboxName(newName, false)
1329 xcheckuserf(ctx, err, "checking new mailbox name")
1331 acc.WithWLock(func() {
1332 var changes []store.Change
1334 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1335 mbsrc := xmailboxID(ctx, tx, mailboxID)
1337 var isInbox, notExists, alreadyExists bool
1338 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1339 if isInbox || notExists || alreadyExists {
1340 xcheckuserf(ctx, err, "renaming mailbox")
1342 xcheckf(ctx, err, "renaming mailbox")
1345 store.BroadcastChanges(acc, changes)
1349// CompleteRecipient returns autocomplete matches for a recipient, returning the
1350// matches, most recently used first, and whether this is the full list and further
1351// requests for longer prefixes aren't necessary.
1352func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1353 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1354 acc := reqInfo.Account
1356 search = strings.ToLower(search)
1358 var matches []string
1360 acc.WithRLock(func() {
1361 xdbread(ctx, acc, func(tx *bstore.Tx) {
1366 seen := map[key]bool{}
1368 q := bstore.QueryTx[store.Recipient](tx)
1370 err := q.ForEach(func(r store.Recipient) error {
1371 k := key{r.Localpart, r.Domain}
1375 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1376 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1377 if !strings.Contains(strings.ToLower(address), search) {
1380 if len(matches) >= 20 {
1382 return bstore.StopForEach
1385 // Look in the message that was sent for a name along with the address.
1386 m := store.Message{ID: r.MessageID}
1388 xcheckf(ctx, err, "get sent message")
1389 if !m.Expunged && m.ParsedBuf != nil {
1390 var part message.Part
1391 err := json.Unmarshal(m.ParsedBuf, &part)
1392 xcheckf(ctx, err, "parsing part")
1394 dom, err := dns.ParseDomain(r.Domain)
1395 xcheckf(ctx, err, "parsing domain of recipient")
1399 checkAddrs := func(l []message.Address) {
1403 for _, a := range l {
1404 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1406 address = addressString(a, false)
1411 if part.Envelope != nil {
1412 env := part.Envelope
1419 matches = append(matches, address)
1423 xcheckf(ctx, err, "listing recipients")
1429// addressString returns an address into a string as it could be used in a message header.
1430func addressString(a message.Address, smtputf8 bool) string {
1432 dom, err := dns.ParseDomain(a.Host)
1434 if smtputf8 && dom.Unicode != "" {
1440 s := "<" + a.User + "@" + host + ">"
1442 // todo: properly encoded/escaped name
1443 s = a.Name + " " + s
1448// MailboxSetSpecialUse sets the special use flags of a mailbox.
1449func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1450 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1451 acc := reqInfo.Account
1453 acc.WithWLock(func() {
1454 var changes []store.Change
1456 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1457 xmb := xmailboxID(ctx, tx, mb.ID)
1459 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1460 // we set, we clear it for the mailbox(es) that had it, if any.
1461 clearPrevious := func(clear bool, specialUse string) {
1465 var ombl []store.Mailbox
1466 q := bstore.QueryTx[store.Mailbox](tx)
1467 q.FilterNotEqual("ID", mb.ID)
1468 q.FilterEqual(specialUse, true)
1470 _, err := q.UpdateField(specialUse, false)
1471 xcheckf(ctx, err, "updating previous special-use mailboxes")
1473 for _, omb := range ombl {
1474 changes = append(changes, omb.ChangeSpecialUse())
1477 clearPrevious(mb.Archive, "Archive")
1478 clearPrevious(mb.Draft, "Draft")
1479 clearPrevious(mb.Junk, "Junk")
1480 clearPrevious(mb.Sent, "Sent")
1481 clearPrevious(mb.Trash, "Trash")
1483 xmb.SpecialUse = mb.SpecialUse
1484 err := tx.Update(&xmb)
1485 xcheckf(ctx, err, "updating special-use flags for mailbox")
1486 changes = append(changes, xmb.ChangeSpecialUse())
1489 store.BroadcastChanges(acc, changes)
1493// ThreadCollapse saves the ThreadCollapse field for the messages and its
1494// children. The messageIDs are typically thread roots. But not all roots
1495// (without parent) of a thread need to have the same collapsed state.
1496func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1497 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1498 acc := reqInfo.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 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1552 acc := reqInfo.Account
1554 if len(messageIDs) == 0 {
1555 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1558 acc.WithWLock(func() {
1559 changes := make([]store.Change, 0, len(messageIDs))
1560 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1561 threadIDs := map[int64]struct{}{}
1562 msgIDs := map[int64]struct{}{}
1563 for _, id := range messageIDs {
1564 m := store.Message{ID: id}
1566 if err == bstore.ErrAbsent {
1567 xcheckuserf(ctx, err, "get message")
1569 xcheckf(ctx, err, "get message")
1570 threadIDs[m.ThreadID] = struct{}{}
1571 msgIDs[id] = struct{}{}
1574 var updated []store.Message
1576 q := bstore.QueryTx[store.Message](tx)
1577 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1578 q.FilterFn(func(tm store.Message) bool {
1579 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1582 for _, id := range tm.ThreadParentIDs {
1583 if _, ok := msgIDs[id]; ok {
1587 _, ok := msgIDs[tm.ID]
1591 fields := map[string]any{"ThreadMuted": mute}
1593 fields["ThreadCollapsed"] = true
1595 _, err := q.UpdateFields(fields)
1596 xcheckf(ctx, err, "updating mute in database")
1598 for _, m := range updated {
1599 changes = append(changes, m.ChangeThread())
1602 store.BroadcastChanges(acc, changes)
1606// SecurityResult indicates whether a security feature is supported.
1607type SecurityResult string
1610 SecurityResultError SecurityResult = "error"
1611 SecurityResultNo SecurityResult = "no"
1612 SecurityResultYes SecurityResult = "yes"
1613 // Unknown whether supported. Finding out may only be (reasonably) possible when
1614 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1616 SecurityResultUnknown SecurityResult = "unknown"
1619// RecipientSecurity is a quick analysis of the security properties of delivery to
1620// the recipient (domain).
1621type RecipientSecurity struct {
1622 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1623 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1625 STARTTLS SecurityResult
1627 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1629 MTASTS SecurityResult
1631 // Whether MX lookup response was DNSSEC-signed.
1632 DNSSEC SecurityResult
1634 // Whether first delivery destination has DANE records.
1637 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1638 // Will be "unknown" if no delivery to the domain has been attempted yet.
1639 RequireTLS SecurityResult
1642// RecipientSecurity looks up security properties of the address in the
1643// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1644func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1645 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1648 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1649 return recipientSecurity(ctx, log, resolver, messageAddressee)
1652// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1653func logPanic(ctx context.Context) {
1658 log := pkglog.WithContext(ctx)
1659 log.Error("recover from panic", slog.Any("panic", x))
1661 metrics.PanicInc(metrics.Webmail)
1664// separate function for testing with mocked resolver.
1665func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1666 rs := RecipientSecurity{
1667 SecurityResultUnknown,
1668 SecurityResultUnknown,
1669 SecurityResultUnknown,
1670 SecurityResultUnknown,
1671 SecurityResultUnknown,
1674 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1675 msgAddr, err := parser.Parse(messageAddressee)
1677 return rs, fmt.Errorf("parsing addressee: %v", err)
1679 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1681 return rs, fmt.Errorf("parsing address: %v", err)
1684 var wg sync.WaitGroup
1692 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1693 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1694 rs.MTASTS = SecurityResultYes
1695 } else if err == nil {
1696 rs.MTASTS = SecurityResultNo
1698 rs.MTASTS = SecurityResultError
1708 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1710 rs.DNSSEC = SecurityResultError
1713 if origNextHopAuthentic && expandedNextHopAuthentic {
1714 rs.DNSSEC = SecurityResultYes
1716 rs.DNSSEC = SecurityResultNo
1719 if !origNextHopAuthentic {
1720 rs.DANE = SecurityResultNo
1724 // We're only looking at the first host to deliver to (typically first mx destination).
1725 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1726 return // Should not happen.
1730 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1731 // error result instead of no-DANE result.
1732 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1734 rs.DANE = SecurityResultError
1738 rs.DANE = SecurityResultNo
1742 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1744 rs.DANE = SecurityResultError
1746 } else if daneRequired {
1747 rs.DANE = SecurityResultYes
1749 rs.DANE = SecurityResultNo
1753 // STARTTLS and RequireTLS
1754 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1755 acc := reqInfo.Account
1757 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1758 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1759 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1761 if err == bstore.ErrAbsent {
1763 } else if err != nil {
1764 rs.STARTTLS = SecurityResultError
1765 rs.RequireTLS = SecurityResultError
1766 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1770 rs.STARTTLS = SecurityResultYes
1772 rs.STARTTLS = SecurityResultNo
1775 rs.RequireTLS = SecurityResultYes
1777 rs.RequireTLS = SecurityResultNo
1781 xcheckf(ctx, err, "lookup recipient domain")
1788// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1789func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1790 s, err := wordDecoder.DecodeHeader(text)
1791 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1795// SettingsSave saves settings, e.g. for composing.
1796func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1797 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1798 acc := reqInfo.Account
1801 err := acc.DB.Update(ctx, &settings)
1802 xcheckf(ctx, err, "save settings")
1805func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1806 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1807 acc := reqInfo.Account
1810 xdbread(ctx, acc, func(tx *bstore.Tx) {
1811 m := xmessageID(ctx, tx, msgID)
1812 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1813 mbDst := xmailboxID(ctx, tx, mbDstID)
1815 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1818 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1820 conf, _ := acc.Conf()
1821 dest := conf.Destinations[rcptTo] // May not be present.
1822 defaultMailbox := "Inbox"
1823 if dest.Mailbox != "" {
1824 defaultMailbox = dest.Mailbox
1827 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1828 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1832 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1833 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1834 xcheckf(ctx, err, "looking up previous response for source mailbox")
1838 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1839 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1844 // Parse message for List-Id header.
1845 state := msgState{acc: acc}
1847 pm, err := parsedMessage(log, m, &state, true, false)
1848 xcheckf(ctx, err, "parsing message")
1850 // The suggested ruleset. Once all is checked, we'll return it.
1851 var nrs *config.Ruleset
1853 // If List-Id header is present, we'll treat it as a (mailing) list message.
1854 if l, ok := pm.Headers["List-Id"]; ok {
1856 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1859 var listIDDom dns.Domain
1860 listID, listIDDom = parseListID(l[0])
1862 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1866 // Check if we have a previous "No" answer for this list-id.
1867 no := store.RulesetNoListID{
1868 RcptToAddress: rcptTo,
1870 ToInbox: mbDst.Name == "Inbox",
1872 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1873 xcheckf(ctx, err, "looking up previous response for list-id")
1878 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1879 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1880 // are subscribed to, and take advantage of any reduced junk filtering.
1881 listIDDomStr := listIDDom.Name()
1883 doms := m.DKIMDomains
1884 if m.MailFromValidated {
1885 doms = append(doms, m.MailFromDomain)
1887 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1888 // of SPF verification of one host.
1889 sort.Slice(doms, func(i, j int) bool {
1890 return len(doms[i]) < len(doms[j])
1892 var listAllowDom string
1893 for _, dom := range doms {
1894 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1899 if listAllowDom == "" {
1903 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1904 nrs = &config.Ruleset{
1905 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1906 ListAllowDomain: listAllowDom,
1907 Mailbox: mbDst.Name,
1910 // Otherwise, try to make a rule based on message "From" address.
1911 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1914 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1916 no := store.RulesetNoMsgFrom{
1917 RcptToAddress: rcptTo,
1918 MsgFromAddress: msgFrom,
1919 ToInbox: mbDst.Name == "Inbox",
1921 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1922 xcheckf(ctx, err, "looking up previous response for message from address")
1927 nrs = &config.Ruleset{
1928 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1929 Mailbox: mbDst.Name,
1933 // Only suggest adding/removing rule if it isn't/is present.
1935 for _, rs := range dest.Rulesets {
1936 xrs := config.Ruleset{
1937 MsgFromRegexp: rs.MsgFromRegexp,
1938 HeadersRegexp: rs.HeadersRegexp,
1939 ListAllowDomain: rs.ListAllowDomain,
1940 Mailbox: nrs.Mailbox,
1942 if xrs.Equal(*nrs) {
1947 isRemove = mbDst.Name == defaultMailbox
1949 nrs.Mailbox = mbSrc.Name
1951 if isRemove && !have || !isRemove && have {
1955 // We'll be returning a suggested ruleset.
1956 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1962// Parse the list-id value (the value between <>) from a list-id header.
1963// Returns an empty string if it couldn't be parsed.
1964func parseListID(s string) (listID string, dom dns.Domain) {
1966 s = strings.TrimRight(s, " \t")
1967 if !strings.HasSuffix(s, ">") {
1968 return "", dns.Domain{}
1971 t := strings.Split(s, "<")
1973 return "", dns.Domain{}
1976 dom, err := dns.ParseDomain(s)
1983func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1984 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1986 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1987 dest, ok := acc.Destinations[rcptTo]
1989 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1990 xcheckuserf(ctx, errors.New("destination address not found in account (hint: if this is a catchall address, configure the address explicitly to configure rulesets)"), "looking up address")
1993 nd := map[string]config.Destination{}
1994 for addr, d := range acc.Destinations {
1997 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
1999 acc.Destinations = nd
2001 xcheckf(ctx, err, "saving account with new ruleset")
2004func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2005 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2007 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2008 dest, ok := acc.Destinations[rcptTo]
2010 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2013 nd := map[string]config.Destination{}
2014 for addr, d := range acc.Destinations {
2017 var l []config.Ruleset
2019 for _, rs := range dest.Rulesets {
2020 if rs.Equal(ruleset) {
2027 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2031 acc.Destinations = nd
2033 xcheckf(ctx, err, "saving account with new ruleset")
2036func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2037 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2038 acc := reqInfo.Account
2042 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2044 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2046 xcheckf(ctx, err, "storing user response")
2049func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2050 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2051 acc := reqInfo.Account
2053 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2054 xcheckf(ctx, err, "storing user response")
2057func slicesAny[T any](l []T) []any {
2058 r := make([]any, len(l))
2059 for i, v := range l {
2065// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2066func (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) {