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()
207 } else if err != nil {
208 return store.ModeText, err
210 return fas.ViewMode, nil
213// FromAddressSettingsSave saves per-"From"-address settings.
214func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
215 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
216 acc := reqInfo.Account
218 if fas.FromAddress == "" {
219 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
222 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
223 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
224 err := tx.Update(&fas)
225 xcheckf(ctx, err, "updating settings for from address")
227 err := tx.Insert(&fas)
228 xcheckf(ctx, err, "inserting settings for from address")
233// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
234// of the message in storage. Used when opening a previously saved draft message
236// If no message is find, zero is returned, not an error.
237func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
238 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
239 acc := reqInfo.Account
241 messageID, _, _ = message.MessageIDCanonical(messageID)
243 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
246 xdbread(ctx, acc, func(tx *bstore.Tx) {
247 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
248 if err == bstore.ErrAbsent {
251 xcheckf(ctx, err, "looking up message by message-id")
257// ComposeMessage is a message to be composed, for saving draft messages.
258type ComposeMessage struct {
263 ReplyTo string // If non-empty, Reply-To header to add to message.
266 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
267 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
270// MessageCompose composes a message and saves it to the mailbox. Used for
271// saving draft messages.
272func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
273 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
274 acc := reqInfo.Account
277 log.Debug("message compose")
279 // Prevent any accidental control characters, or attempts at getting bare \r or \n
281 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
282 for _, s := range l {
283 for _, c := range s {
285 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
291 fromAddr, err := parseAddress(m.From)
292 xcheckuserf(ctx, err, "parsing From address")
294 var replyTo *message.NameAddress
296 addr, err := parseAddress(m.ReplyTo)
297 xcheckuserf(ctx, err, "parsing Reply-To address")
301 var recipients []smtp.Address
303 var toAddrs []message.NameAddress
304 for _, s := range m.To {
305 addr, err := parseAddress(s)
306 xcheckuserf(ctx, err, "parsing To address")
307 toAddrs = append(toAddrs, addr)
308 recipients = append(recipients, addr.Address)
311 var ccAddrs []message.NameAddress
312 for _, s := range m.Cc {
313 addr, err := parseAddress(s)
314 xcheckuserf(ctx, err, "parsing Cc address")
315 ccAddrs = append(ccAddrs, addr)
316 recipients = append(recipients, addr.Address)
319 var bccAddrs []message.NameAddress
320 for _, s := range m.Bcc {
321 addr, err := parseAddress(s)
322 xcheckuserf(ctx, err, "parsing Bcc address")
323 bccAddrs = append(bccAddrs, addr)
324 recipients = append(recipients, addr.Address)
327 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
329 for _, a := range recipients {
330 if a.Localpart.IsInternational() {
335 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
336 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
339 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
343 // Create file to compose message into.
344 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
345 xcheckf(ctx, err, "creating temporary file for compose message")
346 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
348 // If writing to the message file fails, we abort immediately.
349 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
355 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
356 xcheckuserf(ctx, err, "making message")
357 } else if ok && errors.Is(err, message.ErrCompose) {
358 xcheckf(ctx, err, "making message")
363 // Outer message headers.
364 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
366 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
368 xc.HeaderAddrs("To", toAddrs)
369 xc.HeaderAddrs("Cc", ccAddrs)
370 xc.HeaderAddrs("Bcc", bccAddrs)
372 xc.Subject(m.Subject)
375 // Add In-Reply-To and References headers.
376 if m.ResponseMessageID > 0 {
377 xdbread(ctx, acc, func(tx *bstore.Tx) {
378 rm := xmessageID(ctx, tx, m.ResponseMessageID)
379 msgr := acc.MessageReader(rm)
382 log.Check(err, "closing message reader")
384 rp, err := rm.LoadPart(msgr)
385 xcheckf(ctx, err, "load parsed message")
386 h, err := rp.Header()
387 xcheckf(ctx, err, "parsing header")
389 if rp.Envelope == nil {
393 if rp.Envelope.MessageID != "" {
394 xc.Header("In-Reply-To", rp.Envelope.MessageID)
396 refs := h.Values("References")
397 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
398 refs = []string{rp.Envelope.InReplyTo}
400 if rp.Envelope.MessageID != "" {
401 refs = append(refs, rp.Envelope.MessageID)
404 xc.Header("References", strings.Join(refs, "\r\n\t"))
408 xc.Header("MIME-Version", "1.0")
409 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
410 xc.Header("Content-Type", ct)
411 xc.Header("Content-Transfer-Encoding", cte)
413 xc.Write([]byte(textBody))
418 // Remove previous draft message, append message to destination mailbox.
419 acc.WithRLock(func() {
420 var changes []store.Change
422 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
423 var modseq store.ModSeq // Only set if needed.
425 if m.DraftMessageID > 0 {
426 var nchanges []store.Change
427 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
428 changes = append(changes, nchanges...)
429 // On-disk file is removed after lock.
432 // Find mailbox to write to.
433 mb := store.Mailbox{ID: mailboxID}
435 if err == bstore.ErrAbsent {
436 xcheckuserf(ctx, err, "looking up mailbox")
438 xcheckf(ctx, err, "looking up mailbox")
441 modseq, err = acc.NextModSeq(tx)
442 xcheckf(ctx, err, "next modseq")
449 MailboxOrigID: mb.ID,
450 Flags: store.Flags{Notjunk: true},
454 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
455 xcheckf(ctx, err, "checking quota")
457 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
460 // Update mailbox before delivery, which changes uidnext.
461 mb.Add(nm.MailboxCounts())
463 xcheckf(ctx, err, "updating sent mailbox for counts")
465 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
466 xcheckf(ctx, err, "storing message in mailbox")
468 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
471 store.BroadcastChanges(acc, changes)
474 // Remove on-disk file for removed draft message.
475 if m.DraftMessageID > 0 {
476 p := acc.MessagePath(m.DraftMessageID)
478 log.Check(err, "removing draft message file")
484// Attachment is a MIME part is an existing message that is not intended as
485// viewable text or HTML part.
486type Attachment struct {
487 Path []int // Indices into top-level message.Part.Parts.
489 // File name based on "name" attribute of "Content-Type", or the "filename"
490 // attribute of "Content-Disposition".
496// SubmitMessage is an email message to be sent to one or more recipients.
497// Addresses are formatted as just email address, or with a name like "name
499type SubmitMessage struct {
504 ReplyTo string // If non-empty, Reply-To header to add to message.
508 ForwardAttachments ForwardAttachments
510 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
511 UserAgent string // User-Agent header added if not empty.
512 RequireTLS *bool // For "Require TLS" extension during delivery.
513 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
514 ArchiveThread bool // If set, thread is archived after sending message.
515 DraftMessageID int64 // If set, draft message that will be removed after sending.
518// ForwardAttachments references attachments by a list of message.Part paths.
519type ForwardAttachments struct {
520 MessageID int64 // Only relevant if MessageID is not 0.
521 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
524// File is a new attachment (not from an existing message that is being
525// forwarded) to send with a SubmitMessage.
528 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
531// parseAddress expects either a plain email address like "user@domain", or a
532// single address as used in a message header, like "name <user@domain>".
533func parseAddress(msghdr string) (message.NameAddress, error) {
535 parser := mail.AddressParser{WordDecoder: &wordDecoder}
536 a, err := parser.Parse(msghdr)
538 return message.NameAddress{}, err
541 path, err := smtp.ParseNetMailAddress(a.Address)
543 return message.NameAddress{}, err
545 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
548func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
550 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
552 mb := store.Mailbox{ID: mailboxID}
554 if err == bstore.ErrAbsent {
555 xcheckuserf(ctx, err, "getting mailbox")
557 xcheckf(ctx, err, "getting mailbox")
561// xmessageID returns a non-expunged message or panics with a sherpa error.
562func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
564 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
566 m := store.Message{ID: messageID}
568 if err == bstore.ErrAbsent {
569 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
570 } else if err == nil && m.Expunged {
571 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
573 xcheckf(ctx, err, "getting message")
577func xrandomID(ctx context.Context, n int) string {
578 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
581func xrandom(ctx context.Context, n int) []byte {
582 buf := make([]byte, n)
583 x, err := cryptorand.Read(buf)
584 xcheckf(ctx, err, "read random")
586 xcheckf(ctx, errors.New("short random read"), "read random")
591// MessageSubmit sends a message by submitting it the outgoing email queue. The
592// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
593// Bcc message header.
595// If a Sent mailbox is configured, messages are added to it after submitting
596// to the delivery queue. If Bcc addresses were present, a header is prepended
597// to the message stored in the Sent mailbox.
598func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
599 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
600 acc := reqInfo.Account
603 log.Debug("message submit")
605 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
607 // 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.
609 // Prevent any accidental control characters, or attempts at getting bare \r or \n
611 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
612 for _, s := range l {
613 for _, c := range s {
615 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
621 fromAddr, err := parseAddress(m.From)
622 xcheckuserf(ctx, err, "parsing From address")
624 var replyTo *message.NameAddress
626 a, err := parseAddress(m.ReplyTo)
627 xcheckuserf(ctx, err, "parsing Reply-To address")
631 var recipients []smtp.Address
633 var toAddrs []message.NameAddress
634 for _, s := range m.To {
635 addr, err := parseAddress(s)
636 xcheckuserf(ctx, err, "parsing To address")
637 toAddrs = append(toAddrs, addr)
638 recipients = append(recipients, addr.Address)
641 var ccAddrs []message.NameAddress
642 for _, s := range m.Cc {
643 addr, err := parseAddress(s)
644 xcheckuserf(ctx, err, "parsing Cc address")
645 ccAddrs = append(ccAddrs, addr)
646 recipients = append(recipients, addr.Address)
649 var bccAddrs []message.NameAddress
650 for _, s := range m.Bcc {
651 addr, err := parseAddress(s)
652 xcheckuserf(ctx, err, "parsing Bcc address")
653 bccAddrs = append(bccAddrs, addr)
654 recipients = append(recipients, addr.Address)
657 // Check if from address is allowed for account.
658 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
659 metricSubmission.WithLabelValues("badfrom").Inc()
660 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
663 if len(recipients) == 0 {
664 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
667 // Check outgoing message rate limit.
668 xdbread(ctx, acc, func(tx *bstore.Tx) {
669 rcpts := make([]smtp.Path, len(recipients))
670 for i, r := range recipients {
671 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
673 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
675 metricSubmission.WithLabelValues("messagelimiterror").Inc()
676 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
677 } else if rcptlimit >= 0 {
678 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
679 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
681 xcheckf(ctx, err, "checking send limit")
684 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
686 for _, a := range recipients {
687 if a.Localpart.IsInternational() {
692 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
693 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
696 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
700 // Create file to compose message into.
701 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
702 xcheckf(ctx, err, "creating temporary file for message")
703 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
705 // If writing to the message file fails, we abort immediately.
706 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
712 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
713 xcheckuserf(ctx, err, "making message")
714 } else if ok && errors.Is(err, message.ErrCompose) {
715 xcheckf(ctx, err, "making message")
720 // 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
722 // Each queued message gets a Received header.
723 // We don't have access to the local IP for adding.
724 // We cannot use VIA, because there is no registered method. We would like to use
725 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
726 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
727 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
728 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
729 recvHdrFor := func(rcptTo string) string {
730 recvHdr := &message.HeaderWriter{}
731 // For additional Received-header clauses, see:
732 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
733 // Note: we don't have "via" or "with", there is no registered for webmail.
734 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
735 if reqInfo.Request.TLS != nil {
736 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
738 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
739 return recvHdr.String()
742 // Outer message headers.
743 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
745 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
747 xc.HeaderAddrs("To", toAddrs)
748 xc.HeaderAddrs("Cc", ccAddrs)
749 // We prepend Bcc headers to the message when adding to the Sent mailbox.
751 xc.Subject(m.Subject)
754 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
755 xc.Header("Message-Id", messageID)
756 xc.Header("Date", time.Now().Format(message.RFC5322Z))
757 // Add In-Reply-To and References headers.
758 if m.ResponseMessageID > 0 {
759 xdbread(ctx, acc, func(tx *bstore.Tx) {
760 rm := xmessageID(ctx, tx, m.ResponseMessageID)
761 msgr := acc.MessageReader(rm)
764 log.Check(err, "closing message reader")
766 rp, err := rm.LoadPart(msgr)
767 xcheckf(ctx, err, "load parsed message")
768 h, err := rp.Header()
769 xcheckf(ctx, err, "parsing header")
771 if rp.Envelope == nil {
775 if rp.Envelope.MessageID != "" {
776 xc.Header("In-Reply-To", rp.Envelope.MessageID)
778 refs := h.Values("References")
779 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
780 refs = []string{rp.Envelope.InReplyTo}
782 if rp.Envelope.MessageID != "" {
783 refs = append(refs, rp.Envelope.MessageID)
786 xc.Header("References", strings.Join(refs, "\r\n\t"))
790 if m.UserAgent != "" {
791 xc.Header("User-Agent", m.UserAgent)
793 if m.RequireTLS != nil && !*m.RequireTLS {
794 xc.Header("TLS-Required", "No")
796 xc.Header("MIME-Version", "1.0")
798 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
799 mp := multipart.NewWriter(xc)
800 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
803 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
804 textHdr := textproto.MIMEHeader{}
805 textHdr.Set("Content-Type", ct)
806 textHdr.Set("Content-Transfer-Encoding", cte)
808 textp, err := mp.CreatePart(textHdr)
809 xcheckf(ctx, err, "adding text part to message")
810 _, err = textp.Write(textBody)
811 xcheckf(ctx, err, "writing text part")
813 xaddPart := func(ct, filename string) io.Writer {
814 ahdr := textproto.MIMEHeader{}
815 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
817 ahdr.Set("Content-Type", ct)
818 ahdr.Set("Content-Transfer-Encoding", "base64")
819 ahdr.Set("Content-Disposition", cd)
820 ap, err := mp.CreatePart(ahdr)
821 xcheckf(ctx, err, "adding attachment part to message")
825 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
826 ap := xaddPart(ct, filename)
828 for len(base64Data) > 0 {
834 line, base64Data = base64Data[:n], base64Data[n:]
835 _, err := ap.Write(line)
836 xcheckf(ctx, err, "writing attachment")
837 _, err = ap.Write([]byte("\r\n"))
838 xcheckf(ctx, err, "writing attachment")
842 xaddAttachment := func(ct, filename string, r io.Reader) {
843 ap := xaddPart(ct, filename)
844 wc := moxio.Base64Writer(ap)
845 _, err := io.Copy(wc, r)
846 xcheckf(ctx, err, "adding attachment")
848 xcheckf(ctx, err, "flushing attachment")
851 for _, a := range m.Attachments {
853 if !strings.HasPrefix(s, "data:") {
854 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
857 t := strings.SplitN(s, ",", 2)
859 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
861 if !strings.HasSuffix(t[0], "base64") {
862 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
864 ct := strings.TrimSuffix(t[0], "base64")
865 ct = strings.TrimSuffix(ct, ";")
867 ct = "application/octet-stream"
869 filename := a.Filename
871 filename = "unnamed.bin"
873 params := map[string]string{"name": filename}
874 ct = mime.FormatMediaType(ct, params)
876 // Ensure base64 is valid, then we'll write the original string.
877 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
878 xcheckuserf(ctx, err, "parsing attachment as base64")
880 xaddAttachmentBase64(ct, filename, []byte(t[1]))
883 if len(m.ForwardAttachments.Paths) > 0 {
884 acc.WithRLock(func() {
885 xdbread(ctx, acc, func(tx *bstore.Tx) {
886 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
887 msgr := acc.MessageReader(fm)
890 log.Check(err, "closing message reader")
893 fp, err := fm.LoadPart(msgr)
894 xcheckf(ctx, err, "load parsed message")
896 for _, path := range m.ForwardAttachments.Paths {
898 for _, xp := range path {
899 if xp < 0 || xp >= len(ap.Parts) {
900 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
905 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
907 filename = "unnamed.bin"
909 params := map[string]string{"name": filename}
910 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
911 params["charset"] = pcharset
913 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
914 ct = mime.FormatMediaType(ct, params)
915 xaddAttachment(ct, filename, ap.Reader())
922 xcheckf(ctx, err, "writing mime multipart")
924 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
925 xc.Header("Content-Type", ct)
926 xc.Header("Content-Transfer-Encoding", cte)
928 xc.Write([]byte(textBody))
933 // Add DKIM-Signature headers.
935 fd := fromAddr.Address.Domain
936 confDom, _ := mox.Conf.Domain(fd)
937 selectors := mox.DKIMSelectors(confDom.DKIM)
938 if len(selectors) > 0 {
939 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
941 metricServerErrors.WithLabelValues("dkimsign").Inc()
943 xcheckf(ctx, err, "sign dkim")
945 msgPrefix = dkimHeaders
948 accConf, _ := acc.Conf()
949 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
950 xcheckf(ctx, err, "parsing login address")
951 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
952 fromPath := fromAddr.Address.Path()
953 var localpartBase string
955 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
957 qml := make([]queue.Msg, len(recipients))
959 for i, rcpt := range recipients {
963 fromID = xrandomID(ctx, 16)
964 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
967 // Don't use per-recipient unique message prefix when multiple recipients are
968 // present, or the queue cannot deliver it in a single smtp transaction.
970 if len(recipients) == 1 {
971 recvRcpt = rcpt.Pack(smtputf8)
973 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
974 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
976 Localpart: rcpt.Localpart,
977 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
979 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
980 if m.FutureRelease != nil {
981 ival := time.Until(*m.FutureRelease)
983 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
984 } else if ival > queue.FutureReleaseIntervalMax {
985 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
987 qm.NextAttempt = *m.FutureRelease
988 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
989 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
992 // no qm.Extra from webmail
995 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
997 metricSubmission.WithLabelValues("queueerror").Inc()
999 xcheckf(ctx, err, "adding messages to the delivery queue")
1000 metricSubmission.WithLabelValues("ok").Inc()
1002 var modseq store.ModSeq // Only set if needed.
1004 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1005 // remove any draft message.
1006 acc.WithRLock(func() {
1007 var changes []store.Change
1011 if x := recover(); x != nil {
1013 metricServerErrors.WithLabelValues("submit").Inc()
1018 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1019 if m.DraftMessageID > 0 {
1020 var nchanges []store.Change
1021 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1022 changes = append(changes, nchanges...)
1023 // On-disk file is removed after lock.
1026 if m.ResponseMessageID > 0 {
1027 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1034 if !rm.Junk && !rm.Notjunk {
1037 if rm.Flags != oflags {
1038 modseq, err = acc.NextModSeq(tx)
1039 xcheckf(ctx, err, "next modseq")
1041 err := tx.Update(&rm)
1042 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1043 changes = append(changes, rm.ChangeFlags(oflags))
1045 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1046 xcheckf(ctx, err, "retraining messages after reply/forward")
1049 // Move messages from this thread still in this mailbox to the designated Archive
1051 if m.ArchiveThread {
1052 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1053 if err == bstore.ErrAbsent {
1054 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1056 xcheckf(ctx, err, "looking up designated archive mailbox")
1059 q := bstore.QueryTx[store.Message](tx)
1060 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID})
1061 q.FilterEqual("Expunged", false)
1062 err = q.IDs(&msgIDs)
1063 xcheckf(ctx, err, "listing messages in thread to archive")
1064 if len(msgIDs) > 0 {
1065 var nchanges []store.Change
1066 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1067 changes = append(changes, nchanges...)
1072 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1073 if err == bstore.ErrAbsent {
1074 // There is no mailbox designated as Sent mailbox, so we're done.
1077 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1080 modseq, err = acc.NextModSeq(tx)
1081 xcheckf(ctx, err, "next modseq")
1084 // If there were bcc headers, prepend those to the stored message only, before the
1085 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1086 // message won't validate with DKIM anymore, which is fine.
1087 if len(bccAddrs) > 0 {
1088 var sb strings.Builder
1089 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1090 xbcc.HeaderAddrs("Bcc", bccAddrs)
1092 msgPrefix = sb.String() + msgPrefix
1095 sentm := store.Message{
1098 MailboxID: sentmb.ID,
1099 MailboxOrigID: sentmb.ID,
1100 Flags: store.Flags{Notjunk: true, Seen: true},
1101 Size: int64(len(msgPrefix)) + xc.Size,
1102 MsgPrefix: []byte(msgPrefix),
1105 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1106 xcheckf(ctx, err, "checking quota")
1108 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1111 // Update mailbox before delivery, which changes uidnext.
1112 sentmb.Add(sentm.MailboxCounts())
1113 err = tx.Update(&sentmb)
1114 xcheckf(ctx, err, "updating sent mailbox for counts")
1116 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1118 metricSubmission.WithLabelValues("storesenterror").Inc()
1121 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1123 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1126 store.BroadcastChanges(acc, changes)
1129 // Remove on-disk file for removed draft message.
1130 if m.DraftMessageID > 0 {
1131 p := acc.MessagePath(m.DraftMessageID)
1133 log.Check(err, "removing draft message file")
1137// MessageMove moves messages to another mailbox. If the message is already in
1138// the mailbox an error is returned.
1139func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1140 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1141 acc := reqInfo.Account
1144 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1147var xops = webops.XOps{
1150 Checkuserf: xcheckuserf,
1153// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1154func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1155 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1156 acc := reqInfo.Account
1159 if len(messageIDs) == 0 {
1163 xops.MessageDelete(ctx, log, acc, messageIDs)
1166// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1167// flags should be lower-case, but will be converted and verified.
1168func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1169 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1170 acc := reqInfo.Account
1173 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1176// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1177func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1178 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1179 acc := reqInfo.Account
1182 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1185// MailboxCreate creates a new mailbox.
1186func (Webmail) MailboxCreate(ctx context.Context, name string) {
1187 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1188 acc := reqInfo.Account
1191 name, _, err = store.CheckMailboxName(name, false)
1192 xcheckuserf(ctx, err, "checking mailbox name")
1194 acc.WithWLock(func() {
1195 var changes []store.Change
1196 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1199 changes, _, exists, err = acc.MailboxCreate(tx, name)
1201 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1203 xcheckf(ctx, err, "creating mailbox")
1206 store.BroadcastChanges(acc, changes)
1210// MailboxDelete deletes a mailbox and all its messages.
1211func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1212 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1213 acc := reqInfo.Account
1216 // Messages to remove after having broadcasted the removal of messages.
1217 var removeMessageIDs []int64
1219 acc.WithWLock(func() {
1220 var changes []store.Change
1222 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1223 mb := xmailboxID(ctx, tx, mailboxID)
1224 if mb.Name == "Inbox" {
1225 // Inbox is special in IMAP and cannot be removed.
1226 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1229 var hasChildren bool
1231 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1233 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1235 xcheckf(ctx, err, "deleting mailbox")
1238 store.BroadcastChanges(acc, changes)
1241 for _, mID := range removeMessageIDs {
1242 p := acc.MessagePath(mID)
1244 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1248// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1249// its child mailboxes.
1250func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1251 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1252 acc := reqInfo.Account
1255 var expunged []store.Message
1257 acc.WithWLock(func() {
1258 var changes []store.Change
1260 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1261 mb := xmailboxID(ctx, tx, mailboxID)
1263 modseq, err := acc.NextModSeq(tx)
1264 xcheckf(ctx, err, "next modseq")
1266 // Mark messages as expunged.
1267 qm := bstore.QueryTx[store.Message](tx)
1268 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1269 qm.FilterEqual("Expunged", false)
1271 qm.Gather(&expunged)
1272 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1273 xcheckf(ctx, err, "deleting messages")
1275 // Remove Recipients.
1276 anyIDs := make([]any, len(expunged))
1277 for i, m := range expunged {
1280 qmr := bstore.QueryTx[store.Recipient](tx)
1281 qmr.FilterEqual("MessageID", anyIDs...)
1282 _, err = qmr.Delete()
1283 xcheckf(ctx, err, "removing message recipients")
1285 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1287 uids := make([]store.UID, len(expunged))
1288 for i, m := range expunged {
1289 m.Expunged = false // Gather returns updated values.
1290 mb.Sub(m.MailboxCounts())
1294 expunged[i].Junk = false
1295 expunged[i].Notjunk = false
1298 err = tx.Update(&mb)
1299 xcheckf(ctx, err, "updating mailbox for counts")
1301 err = acc.AddMessageSize(log, tx, -totalSize)
1302 xcheckf(ctx, err, "updating disk usage")
1304 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1305 xcheckf(ctx, err, "retraining expunged messages")
1307 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1308 changes = []store.Change{chremove, mb.ChangeCounts()}
1311 store.BroadcastChanges(acc, changes)
1314 for _, m := range expunged {
1315 p := acc.MessagePath(m.ID)
1317 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1321// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1322// ID and its messages are unchanged.
1323func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1324 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1325 acc := reqInfo.Account
1327 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1328 // standard. We can just say no.
1330 newName, _, err = store.CheckMailboxName(newName, false)
1331 xcheckuserf(ctx, err, "checking new mailbox name")
1333 acc.WithWLock(func() {
1334 var changes []store.Change
1336 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1337 mbsrc := xmailboxID(ctx, tx, mailboxID)
1339 var isInbox, notExists, alreadyExists bool
1340 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1341 if isInbox || notExists || alreadyExists {
1342 xcheckuserf(ctx, err, "renaming mailbox")
1344 xcheckf(ctx, err, "renaming mailbox")
1347 store.BroadcastChanges(acc, changes)
1351// CompleteRecipient returns autocomplete matches for a recipient, returning the
1352// matches, most recently used first, and whether this is the full list and further
1353// requests for longer prefixes aren't necessary.
1354func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1355 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1356 acc := reqInfo.Account
1358 search = strings.ToLower(search)
1360 var matches []string
1362 acc.WithRLock(func() {
1363 xdbread(ctx, acc, func(tx *bstore.Tx) {
1368 seen := map[key]bool{}
1370 q := bstore.QueryTx[store.Recipient](tx)
1372 err := q.ForEach(func(r store.Recipient) error {
1373 k := key{r.Localpart, r.Domain}
1377 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1378 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1379 if !strings.Contains(strings.ToLower(address), search) {
1382 if len(matches) >= 20 {
1384 return bstore.StopForEach
1387 // Look in the message that was sent for a name along with the address.
1388 m := store.Message{ID: r.MessageID}
1390 xcheckf(ctx, err, "get sent message")
1391 if !m.Expunged && m.ParsedBuf != nil {
1392 var part message.Part
1393 err := json.Unmarshal(m.ParsedBuf, &part)
1394 xcheckf(ctx, err, "parsing part")
1396 dom, err := dns.ParseDomain(r.Domain)
1397 xcheckf(ctx, err, "parsing domain of recipient")
1401 checkAddrs := func(l []message.Address) {
1405 for _, a := range l {
1406 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1408 address = addressString(a, false)
1413 if part.Envelope != nil {
1414 env := part.Envelope
1421 matches = append(matches, address)
1425 xcheckf(ctx, err, "listing recipients")
1431// addressString returns an address into a string as it could be used in a message header.
1432func addressString(a message.Address, smtputf8 bool) string {
1434 dom, err := dns.ParseDomain(a.Host)
1436 if smtputf8 && dom.Unicode != "" {
1442 s := "<" + a.User + "@" + host + ">"
1444 // todo: properly encoded/escaped name
1445 s = a.Name + " " + s
1450// MailboxSetSpecialUse sets the special use flags of a mailbox.
1451func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1452 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1453 acc := reqInfo.Account
1455 acc.WithWLock(func() {
1456 var changes []store.Change
1458 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1459 xmb := xmailboxID(ctx, tx, mb.ID)
1461 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1462 // we set, we clear it for the mailbox(es) that had it, if any.
1463 clearPrevious := func(clear bool, specialUse string) {
1467 var ombl []store.Mailbox
1468 q := bstore.QueryTx[store.Mailbox](tx)
1469 q.FilterNotEqual("ID", mb.ID)
1470 q.FilterEqual(specialUse, true)
1472 _, err := q.UpdateField(specialUse, false)
1473 xcheckf(ctx, err, "updating previous special-use mailboxes")
1475 for _, omb := range ombl {
1476 changes = append(changes, omb.ChangeSpecialUse())
1479 clearPrevious(mb.Archive, "Archive")
1480 clearPrevious(mb.Draft, "Draft")
1481 clearPrevious(mb.Junk, "Junk")
1482 clearPrevious(mb.Sent, "Sent")
1483 clearPrevious(mb.Trash, "Trash")
1485 xmb.SpecialUse = mb.SpecialUse
1486 err := tx.Update(&xmb)
1487 xcheckf(ctx, err, "updating special-use flags for mailbox")
1488 changes = append(changes, xmb.ChangeSpecialUse())
1491 store.BroadcastChanges(acc, changes)
1495// ThreadCollapse saves the ThreadCollapse field for the messages and its
1496// children. The messageIDs are typically thread roots. But not all roots
1497// (without parent) of a thread need to have the same collapsed state.
1498func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1499 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1500 acc := reqInfo.Account
1502 if len(messageIDs) == 0 {
1503 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1506 acc.WithWLock(func() {
1507 changes := make([]store.Change, 0, len(messageIDs))
1508 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1509 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1510 // (child) messages. Further refined in FilterFn.
1511 threadIDs := map[int64]struct{}{}
1512 msgIDs := map[int64]struct{}{}
1513 for _, id := range messageIDs {
1514 m := store.Message{ID: id}
1516 if err == bstore.ErrAbsent {
1517 xcheckuserf(ctx, err, "get message")
1519 xcheckf(ctx, err, "get message")
1520 threadIDs[m.ThreadID] = struct{}{}
1521 msgIDs[id] = struct{}{}
1524 var updated []store.Message
1525 q := bstore.QueryTx[store.Message](tx)
1526 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1527 q.FilterNotEqual("ThreadCollapsed", collapse)
1528 q.FilterFn(func(tm store.Message) bool {
1529 for _, id := range tm.ThreadParentIDs {
1530 if _, ok := msgIDs[id]; ok {
1534 _, ok := msgIDs[tm.ID]
1538 q.SortAsc("ID") // Consistent order for testing.
1539 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1540 xcheckf(ctx, err, "updating collapse in database")
1542 for _, m := range updated {
1543 changes = append(changes, m.ChangeThread())
1546 store.BroadcastChanges(acc, changes)
1550// ThreadMute saves the ThreadMute field for the messages and their children.
1551// If messages are muted, they are also marked collapsed.
1552func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1553 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1554 acc := reqInfo.Account
1556 if len(messageIDs) == 0 {
1557 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1560 acc.WithWLock(func() {
1561 changes := make([]store.Change, 0, len(messageIDs))
1562 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1563 threadIDs := map[int64]struct{}{}
1564 msgIDs := map[int64]struct{}{}
1565 for _, id := range messageIDs {
1566 m := store.Message{ID: id}
1568 if err == bstore.ErrAbsent {
1569 xcheckuserf(ctx, err, "get message")
1571 xcheckf(ctx, err, "get message")
1572 threadIDs[m.ThreadID] = struct{}{}
1573 msgIDs[id] = struct{}{}
1576 var updated []store.Message
1578 q := bstore.QueryTx[store.Message](tx)
1579 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1580 q.FilterFn(func(tm store.Message) bool {
1581 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1584 for _, id := range tm.ThreadParentIDs {
1585 if _, ok := msgIDs[id]; ok {
1589 _, ok := msgIDs[tm.ID]
1593 fields := map[string]any{"ThreadMuted": mute}
1595 fields["ThreadCollapsed"] = true
1597 _, err := q.UpdateFields(fields)
1598 xcheckf(ctx, err, "updating mute in database")
1600 for _, m := range updated {
1601 changes = append(changes, m.ChangeThread())
1604 store.BroadcastChanges(acc, changes)
1608// SecurityResult indicates whether a security feature is supported.
1609type SecurityResult string
1612 SecurityResultError SecurityResult = "error"
1613 SecurityResultNo SecurityResult = "no"
1614 SecurityResultYes SecurityResult = "yes"
1615 // Unknown whether supported. Finding out may only be (reasonably) possible when
1616 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1618 SecurityResultUnknown SecurityResult = "unknown"
1621// RecipientSecurity is a quick analysis of the security properties of delivery to
1622// the recipient (domain).
1623type RecipientSecurity struct {
1624 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1625 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1627 STARTTLS SecurityResult
1629 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1631 MTASTS SecurityResult
1633 // Whether MX lookup response was DNSSEC-signed.
1634 DNSSEC SecurityResult
1636 // Whether first delivery destination has DANE records.
1639 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1640 // Will be "unknown" if no delivery to the domain has been attempted yet.
1641 RequireTLS SecurityResult
1644// RecipientSecurity looks up security properties of the address in the
1645// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1646func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1647 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1650 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1651 return recipientSecurity(ctx, log, resolver, messageAddressee)
1654// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1655func logPanic(ctx context.Context) {
1660 log := pkglog.WithContext(ctx)
1661 log.Error("recover from panic", slog.Any("panic", x))
1663 metrics.PanicInc(metrics.Webmail)
1666// separate function for testing with mocked resolver.
1667func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1668 rs := RecipientSecurity{
1669 SecurityResultUnknown,
1670 SecurityResultUnknown,
1671 SecurityResultUnknown,
1672 SecurityResultUnknown,
1673 SecurityResultUnknown,
1676 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1677 msgAddr, err := parser.Parse(messageAddressee)
1679 return rs, fmt.Errorf("parsing addressee: %v", err)
1681 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1683 return rs, fmt.Errorf("parsing address: %v", err)
1686 var wg sync.WaitGroup
1694 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1695 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1696 rs.MTASTS = SecurityResultYes
1697 } else if err == nil {
1698 rs.MTASTS = SecurityResultNo
1700 rs.MTASTS = SecurityResultError
1710 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1712 rs.DNSSEC = SecurityResultError
1715 if origNextHopAuthentic && expandedNextHopAuthentic {
1716 rs.DNSSEC = SecurityResultYes
1718 rs.DNSSEC = SecurityResultNo
1721 if !origNextHopAuthentic {
1722 rs.DANE = SecurityResultNo
1726 // We're only looking at the first host to deliver to (typically first mx destination).
1727 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1728 return // Should not happen.
1732 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1733 // error result instead of no-DANE result.
1734 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1736 rs.DANE = SecurityResultError
1740 rs.DANE = SecurityResultNo
1744 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1746 rs.DANE = SecurityResultError
1748 } else if daneRequired {
1749 rs.DANE = SecurityResultYes
1751 rs.DANE = SecurityResultNo
1755 // STARTTLS and RequireTLS
1756 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1757 acc := reqInfo.Account
1759 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1760 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1761 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1763 if err == bstore.ErrAbsent {
1765 } else if err != nil {
1766 rs.STARTTLS = SecurityResultError
1767 rs.RequireTLS = SecurityResultError
1768 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1772 rs.STARTTLS = SecurityResultYes
1774 rs.STARTTLS = SecurityResultNo
1777 rs.RequireTLS = SecurityResultYes
1779 rs.RequireTLS = SecurityResultNo
1783 xcheckf(ctx, err, "lookup recipient domain")
1790// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1791func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1792 s, err := wordDecoder.DecodeHeader(text)
1793 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1797// SettingsSave saves settings, e.g. for composing.
1798func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1799 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1800 acc := reqInfo.Account
1803 err := acc.DB.Update(ctx, &settings)
1804 xcheckf(ctx, err, "save settings")
1807func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1808 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1809 acc := reqInfo.Account
1812 xdbread(ctx, acc, func(tx *bstore.Tx) {
1813 m := xmessageID(ctx, tx, msgID)
1814 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1815 mbDst := xmailboxID(ctx, tx, mbDstID)
1817 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1820 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1822 conf, _ := acc.Conf()
1823 dest := conf.Destinations[rcptTo] // May not be present.
1824 defaultMailbox := "Inbox"
1825 if dest.Mailbox != "" {
1826 defaultMailbox = dest.Mailbox
1829 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1830 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1834 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1835 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1836 xcheckf(ctx, err, "looking up previous response for source mailbox")
1840 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1841 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1846 // Parse message for List-Id header.
1847 state := msgState{acc: acc}
1849 pm, err := parsedMessage(log, m, &state, true, false)
1850 xcheckf(ctx, err, "parsing message")
1852 // The suggested ruleset. Once all is checked, we'll return it.
1853 var nrs *config.Ruleset
1855 // If List-Id header is present, we'll treat it as a (mailing) list message.
1856 if l, ok := pm.Headers["List-Id"]; ok {
1858 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1861 var listIDDom dns.Domain
1862 listID, listIDDom = parseListID(l[0])
1864 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1868 // Check if we have a previous "No" answer for this list-id.
1869 no := store.RulesetNoListID{
1870 RcptToAddress: rcptTo,
1872 ToInbox: mbDst.Name == "Inbox",
1874 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1875 xcheckf(ctx, err, "looking up previous response for list-id")
1880 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1881 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1882 // are subscribed to, and take advantage of any reduced junk filtering.
1883 listIDDomStr := listIDDom.Name()
1885 doms := m.DKIMDomains
1886 if m.MailFromValidated {
1887 doms = append(doms, m.MailFromDomain)
1889 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1890 // of SPF verification of one host.
1891 sort.Slice(doms, func(i, j int) bool {
1892 return len(doms[i]) < len(doms[j])
1894 var listAllowDom string
1895 for _, dom := range doms {
1896 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1901 if listAllowDom == "" {
1905 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1906 nrs = &config.Ruleset{
1907 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1908 ListAllowDomain: listAllowDom,
1909 Mailbox: mbDst.Name,
1912 // Otherwise, try to make a rule based on message "From" address.
1913 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1916 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1918 no := store.RulesetNoMsgFrom{
1919 RcptToAddress: rcptTo,
1920 MsgFromAddress: msgFrom,
1921 ToInbox: mbDst.Name == "Inbox",
1923 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1924 xcheckf(ctx, err, "looking up previous response for message from address")
1929 nrs = &config.Ruleset{
1930 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1931 Mailbox: mbDst.Name,
1935 // Only suggest adding/removing rule if it isn't/is present.
1937 for _, rs := range dest.Rulesets {
1938 xrs := config.Ruleset{
1939 MsgFromRegexp: rs.MsgFromRegexp,
1940 HeadersRegexp: rs.HeadersRegexp,
1941 ListAllowDomain: rs.ListAllowDomain,
1942 Mailbox: nrs.Mailbox,
1944 if xrs.Equal(*nrs) {
1949 isRemove = mbDst.Name == defaultMailbox
1951 nrs.Mailbox = mbSrc.Name
1953 if isRemove && !have || !isRemove && have {
1957 // We'll be returning a suggested ruleset.
1958 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1964// Parse the list-id value (the value between <>) from a list-id header.
1965// Returns an empty string if it couldn't be parsed.
1966func parseListID(s string) (listID string, dom dns.Domain) {
1968 s = strings.TrimRight(s, " \t")
1969 if !strings.HasSuffix(s, ">") {
1970 return "", dns.Domain{}
1973 t := strings.Split(s, "<")
1975 return "", dns.Domain{}
1978 dom, err := dns.ParseDomain(s)
1985func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1986 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1988 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1989 dest, ok := acc.Destinations[rcptTo]
1991 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1992 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")
1995 nd := map[string]config.Destination{}
1996 for addr, d := range acc.Destinations {
1999 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2001 acc.Destinations = nd
2003 xcheckf(ctx, err, "saving account with new ruleset")
2006func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2007 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2009 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2010 dest, ok := acc.Destinations[rcptTo]
2012 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2015 nd := map[string]config.Destination{}
2016 for addr, d := range acc.Destinations {
2019 var l []config.Ruleset
2021 for _, rs := range dest.Rulesets {
2022 if rs.Equal(ruleset) {
2029 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2033 acc.Destinations = nd
2035 xcheckf(ctx, err, "saving account with new ruleset")
2038func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2039 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2040 acc := reqInfo.Account
2044 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2046 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2048 xcheckf(ctx, err, "storing user response")
2051func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2052 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2053 acc := reqInfo.Account
2055 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2056 xcheckf(ctx, err, "storing user response")
2059func slicesAny[T any](l []T) []any {
2060 r := make([]any, len(l))
2061 for i, v := range l {
2067// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2068func (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) {