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 ArchiveReferenceMailboxID int64 // If ArchiveThread is set, thread messages from this mailbox ID are moved to the archive mailbox ID. E.g. of Inbox.
516 DraftMessageID int64 // If set, draft message that will be removed after sending.
519// ForwardAttachments references attachments by a list of message.Part paths.
520type ForwardAttachments struct {
521 MessageID int64 // Only relevant if MessageID is not 0.
522 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
525// File is a new attachment (not from an existing message that is being
526// forwarded) to send with a SubmitMessage.
529 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
532// parseAddress expects either a plain email address like "user@domain", or a
533// single address as used in a message header, like "name <user@domain>".
534func parseAddress(msghdr string) (message.NameAddress, error) {
536 parser := mail.AddressParser{WordDecoder: &wordDecoder}
537 a, err := parser.Parse(msghdr)
539 return message.NameAddress{}, err
542 path, err := smtp.ParseNetMailAddress(a.Address)
544 return message.NameAddress{}, err
546 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
549func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
551 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
553 mb := store.Mailbox{ID: mailboxID}
555 if err == bstore.ErrAbsent {
556 xcheckuserf(ctx, err, "getting mailbox")
558 xcheckf(ctx, err, "getting mailbox")
562// xmessageID returns a non-expunged message or panics with a sherpa error.
563func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
565 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
567 m := store.Message{ID: messageID}
569 if err == bstore.ErrAbsent {
570 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
571 } else if err == nil && m.Expunged {
572 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
574 xcheckf(ctx, err, "getting message")
578func xrandomID(ctx context.Context, n int) string {
579 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
582func xrandom(ctx context.Context, n int) []byte {
583 buf := make([]byte, n)
584 x, err := cryptorand.Read(buf)
585 xcheckf(ctx, err, "read random")
587 xcheckf(ctx, errors.New("short random read"), "read random")
592// MessageSubmit sends a message by submitting it the outgoing email queue. The
593// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
594// Bcc message header.
596// If a Sent mailbox is configured, messages are added to it after submitting
597// to the delivery queue. If Bcc addresses were present, a header is prepended
598// to the message stored in the Sent mailbox.
599func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
600 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
601 acc := reqInfo.Account
604 log.Debug("message submit")
606 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
608 // 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.
610 // Prevent any accidental control characters, or attempts at getting bare \r or \n
612 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
613 for _, s := range l {
614 for _, c := range s {
616 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
622 fromAddr, err := parseAddress(m.From)
623 xcheckuserf(ctx, err, "parsing From address")
625 var replyTo *message.NameAddress
627 a, err := parseAddress(m.ReplyTo)
628 xcheckuserf(ctx, err, "parsing Reply-To address")
632 var recipients []smtp.Address
634 var toAddrs []message.NameAddress
635 for _, s := range m.To {
636 addr, err := parseAddress(s)
637 xcheckuserf(ctx, err, "parsing To address")
638 toAddrs = append(toAddrs, addr)
639 recipients = append(recipients, addr.Address)
642 var ccAddrs []message.NameAddress
643 for _, s := range m.Cc {
644 addr, err := parseAddress(s)
645 xcheckuserf(ctx, err, "parsing Cc address")
646 ccAddrs = append(ccAddrs, addr)
647 recipients = append(recipients, addr.Address)
650 var bccAddrs []message.NameAddress
651 for _, s := range m.Bcc {
652 addr, err := parseAddress(s)
653 xcheckuserf(ctx, err, "parsing Bcc address")
654 bccAddrs = append(bccAddrs, addr)
655 recipients = append(recipients, addr.Address)
658 // Check if from address is allowed for account.
659 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
660 metricSubmission.WithLabelValues("badfrom").Inc()
661 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
664 if len(recipients) == 0 {
665 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
668 // Check outgoing message rate limit.
669 xdbread(ctx, acc, func(tx *bstore.Tx) {
670 rcpts := make([]smtp.Path, len(recipients))
671 for i, r := range recipients {
672 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
674 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
676 metricSubmission.WithLabelValues("messagelimiterror").Inc()
677 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
678 } else if rcptlimit >= 0 {
679 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
680 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
682 xcheckf(ctx, err, "checking send limit")
685 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
687 for _, a := range recipients {
688 if a.Localpart.IsInternational() {
693 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
694 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
697 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
701 // Create file to compose message into.
702 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
703 xcheckf(ctx, err, "creating temporary file for message")
704 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
706 // If writing to the message file fails, we abort immediately.
707 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
713 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
714 xcheckuserf(ctx, err, "making message")
715 } else if ok && errors.Is(err, message.ErrCompose) {
716 xcheckf(ctx, err, "making message")
721 // 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
723 // Each queued message gets a Received header.
724 // We don't have access to the local IP for adding.
725 // We cannot use VIA, because there is no registered method. We would like to use
726 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
727 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
728 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
729 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
730 recvHdrFor := func(rcptTo string) string {
731 recvHdr := &message.HeaderWriter{}
732 // For additional Received-header clauses, see:
733 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
734 // Note: we don't have "via" or "with", there is no registered for webmail.
735 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
736 if reqInfo.Request.TLS != nil {
737 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
739 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
740 return recvHdr.String()
743 // Outer message headers.
744 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
746 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
748 xc.HeaderAddrs("To", toAddrs)
749 xc.HeaderAddrs("Cc", ccAddrs)
750 // We prepend Bcc headers to the message when adding to the Sent mailbox.
752 xc.Subject(m.Subject)
755 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
756 xc.Header("Message-Id", messageID)
757 xc.Header("Date", time.Now().Format(message.RFC5322Z))
758 // Add In-Reply-To and References headers.
759 if m.ResponseMessageID > 0 {
760 xdbread(ctx, acc, func(tx *bstore.Tx) {
761 rm := xmessageID(ctx, tx, m.ResponseMessageID)
762 msgr := acc.MessageReader(rm)
765 log.Check(err, "closing message reader")
767 rp, err := rm.LoadPart(msgr)
768 xcheckf(ctx, err, "load parsed message")
769 h, err := rp.Header()
770 xcheckf(ctx, err, "parsing header")
772 if rp.Envelope == nil {
776 if rp.Envelope.MessageID != "" {
777 xc.Header("In-Reply-To", rp.Envelope.MessageID)
779 refs := h.Values("References")
780 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
781 refs = []string{rp.Envelope.InReplyTo}
783 if rp.Envelope.MessageID != "" {
784 refs = append(refs, rp.Envelope.MessageID)
787 xc.Header("References", strings.Join(refs, "\r\n\t"))
791 if m.UserAgent != "" {
792 xc.Header("User-Agent", m.UserAgent)
794 if m.RequireTLS != nil && !*m.RequireTLS {
795 xc.Header("TLS-Required", "No")
797 xc.Header("MIME-Version", "1.0")
799 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
800 mp := multipart.NewWriter(xc)
801 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
804 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
805 textHdr := textproto.MIMEHeader{}
806 textHdr.Set("Content-Type", ct)
807 textHdr.Set("Content-Transfer-Encoding", cte)
809 textp, err := mp.CreatePart(textHdr)
810 xcheckf(ctx, err, "adding text part to message")
811 _, err = textp.Write(textBody)
812 xcheckf(ctx, err, "writing text part")
814 xaddPart := func(ct, filename string) io.Writer {
815 ahdr := textproto.MIMEHeader{}
816 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
818 ahdr.Set("Content-Type", ct)
819 ahdr.Set("Content-Transfer-Encoding", "base64")
820 ahdr.Set("Content-Disposition", cd)
821 ap, err := mp.CreatePart(ahdr)
822 xcheckf(ctx, err, "adding attachment part to message")
826 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
827 ap := xaddPart(ct, filename)
829 for len(base64Data) > 0 {
835 line, base64Data = base64Data[:n], base64Data[n:]
836 _, err := ap.Write(line)
837 xcheckf(ctx, err, "writing attachment")
838 _, err = ap.Write([]byte("\r\n"))
839 xcheckf(ctx, err, "writing attachment")
843 xaddAttachment := func(ct, filename string, r io.Reader) {
844 ap := xaddPart(ct, filename)
845 wc := moxio.Base64Writer(ap)
846 _, err := io.Copy(wc, r)
847 xcheckf(ctx, err, "adding attachment")
849 xcheckf(ctx, err, "flushing attachment")
852 for _, a := range m.Attachments {
854 if !strings.HasPrefix(s, "data:") {
855 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
858 t := strings.SplitN(s, ",", 2)
860 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
862 if !strings.HasSuffix(t[0], "base64") {
863 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
865 ct := strings.TrimSuffix(t[0], "base64")
866 ct = strings.TrimSuffix(ct, ";")
868 ct = "application/octet-stream"
870 filename := a.Filename
872 filename = "unnamed.bin"
874 params := map[string]string{"name": filename}
875 ct = mime.FormatMediaType(ct, params)
877 // Ensure base64 is valid, then we'll write the original string.
878 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
879 xcheckuserf(ctx, err, "parsing attachment as base64")
881 xaddAttachmentBase64(ct, filename, []byte(t[1]))
884 if len(m.ForwardAttachments.Paths) > 0 {
885 acc.WithRLock(func() {
886 xdbread(ctx, acc, func(tx *bstore.Tx) {
887 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
888 msgr := acc.MessageReader(fm)
891 log.Check(err, "closing message reader")
894 fp, err := fm.LoadPart(msgr)
895 xcheckf(ctx, err, "load parsed message")
897 for _, path := range m.ForwardAttachments.Paths {
899 for _, xp := range path {
900 if xp < 0 || xp >= len(ap.Parts) {
901 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
906 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
908 filename = "unnamed.bin"
910 params := map[string]string{"name": filename}
911 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
912 params["charset"] = pcharset
914 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
915 ct = mime.FormatMediaType(ct, params)
916 xaddAttachment(ct, filename, ap.Reader())
923 xcheckf(ctx, err, "writing mime multipart")
925 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
926 xc.Header("Content-Type", ct)
927 xc.Header("Content-Transfer-Encoding", cte)
929 xc.Write([]byte(textBody))
934 // Add DKIM-Signature headers.
936 fd := fromAddr.Address.Domain
937 confDom, _ := mox.Conf.Domain(fd)
938 selectors := mox.DKIMSelectors(confDom.DKIM)
939 if len(selectors) > 0 {
940 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
942 metricServerErrors.WithLabelValues("dkimsign").Inc()
944 xcheckf(ctx, err, "sign dkim")
946 msgPrefix = dkimHeaders
949 accConf, _ := acc.Conf()
950 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
951 xcheckf(ctx, err, "parsing login address")
952 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
953 fromPath := fromAddr.Address.Path()
954 var localpartBase string
956 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
958 qml := make([]queue.Msg, len(recipients))
960 for i, rcpt := range recipients {
964 fromID = xrandomID(ctx, 16)
965 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
968 // Don't use per-recipient unique message prefix when multiple recipients are
969 // present, or the queue cannot deliver it in a single smtp transaction.
971 if len(recipients) == 1 {
972 recvRcpt = rcpt.Pack(smtputf8)
974 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
975 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
977 Localpart: rcpt.Localpart,
978 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
980 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
981 if m.FutureRelease != nil {
982 ival := time.Until(*m.FutureRelease)
984 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
985 } else if ival > queue.FutureReleaseIntervalMax {
986 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
988 qm.NextAttempt = *m.FutureRelease
989 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
990 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
993 // no qm.Extra from webmail
996 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
998 metricSubmission.WithLabelValues("queueerror").Inc()
1000 xcheckf(ctx, err, "adding messages to the delivery queue")
1001 metricSubmission.WithLabelValues("ok").Inc()
1003 var modseq store.ModSeq // Only set if needed.
1005 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1006 // remove any draft message.
1007 acc.WithRLock(func() {
1008 var changes []store.Change
1012 if x := recover(); x != nil {
1014 metricServerErrors.WithLabelValues("submit").Inc()
1019 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1020 if m.DraftMessageID > 0 {
1021 var nchanges []store.Change
1022 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1023 changes = append(changes, nchanges...)
1024 // On-disk file is removed after lock.
1027 if m.ResponseMessageID > 0 {
1028 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1035 if !rm.Junk && !rm.Notjunk {
1038 if rm.Flags != oflags {
1039 modseq, err = acc.NextModSeq(tx)
1040 xcheckf(ctx, err, "next modseq")
1042 err := tx.Update(&rm)
1043 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1044 changes = append(changes, rm.ChangeFlags(oflags))
1046 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1047 xcheckf(ctx, err, "retraining messages after reply/forward")
1050 // Move messages from this thread still in this mailbox to the designated Archive
1052 if m.ArchiveThread {
1053 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1054 if err == bstore.ErrAbsent {
1055 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1057 xcheckf(ctx, err, "looking up designated archive mailbox")
1060 q := bstore.QueryTx[store.Message](tx)
1061 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1062 q.FilterEqual("Expunged", false)
1063 err = q.IDs(&msgIDs)
1064 xcheckf(ctx, err, "listing messages in thread to archive")
1065 if len(msgIDs) > 0 {
1066 var nchanges []store.Change
1067 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1068 changes = append(changes, nchanges...)
1073 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1074 if err == bstore.ErrAbsent {
1075 // There is no mailbox designated as Sent mailbox, so we're done.
1078 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1081 modseq, err = acc.NextModSeq(tx)
1082 xcheckf(ctx, err, "next modseq")
1085 // If there were bcc headers, prepend those to the stored message only, before the
1086 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1087 // message won't validate with DKIM anymore, which is fine.
1088 if len(bccAddrs) > 0 {
1089 var sb strings.Builder
1090 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1091 xbcc.HeaderAddrs("Bcc", bccAddrs)
1093 msgPrefix = sb.String() + msgPrefix
1096 sentm := store.Message{
1099 MailboxID: sentmb.ID,
1100 MailboxOrigID: sentmb.ID,
1101 Flags: store.Flags{Notjunk: true, Seen: true},
1102 Size: int64(len(msgPrefix)) + xc.Size,
1103 MsgPrefix: []byte(msgPrefix),
1106 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1107 xcheckf(ctx, err, "checking quota")
1109 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1112 // Update mailbox before delivery, which changes uidnext.
1113 sentmb.Add(sentm.MailboxCounts())
1114 err = tx.Update(&sentmb)
1115 xcheckf(ctx, err, "updating sent mailbox for counts")
1117 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1119 metricSubmission.WithLabelValues("storesenterror").Inc()
1122 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1124 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1127 store.BroadcastChanges(acc, changes)
1130 // Remove on-disk file for removed draft message.
1131 if m.DraftMessageID > 0 {
1132 p := acc.MessagePath(m.DraftMessageID)
1134 log.Check(err, "removing draft message file")
1138// MessageMove moves messages to another mailbox. If the message is already in
1139// the mailbox an error is returned.
1140func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1141 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1142 acc := reqInfo.Account
1145 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1148var xops = webops.XOps{
1151 Checkuserf: xcheckuserf,
1154// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1155func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1156 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1157 acc := reqInfo.Account
1160 if len(messageIDs) == 0 {
1164 xops.MessageDelete(ctx, log, acc, messageIDs)
1167// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1168// flags should be lower-case, but will be converted and verified.
1169func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1170 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1171 acc := reqInfo.Account
1174 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1177// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1178func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1179 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1180 acc := reqInfo.Account
1183 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1186// MailboxCreate creates a new mailbox.
1187func (Webmail) MailboxCreate(ctx context.Context, name string) {
1188 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1189 acc := reqInfo.Account
1192 name, _, err = store.CheckMailboxName(name, false)
1193 xcheckuserf(ctx, err, "checking mailbox name")
1195 acc.WithWLock(func() {
1196 var changes []store.Change
1197 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1200 changes, _, exists, err = acc.MailboxCreate(tx, name)
1202 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1204 xcheckf(ctx, err, "creating mailbox")
1207 store.BroadcastChanges(acc, changes)
1211// MailboxDelete deletes a mailbox and all its messages.
1212func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1213 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1214 acc := reqInfo.Account
1217 // Messages to remove after having broadcasted the removal of messages.
1218 var removeMessageIDs []int64
1220 acc.WithWLock(func() {
1221 var changes []store.Change
1223 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1224 mb := xmailboxID(ctx, tx, mailboxID)
1225 if mb.Name == "Inbox" {
1226 // Inbox is special in IMAP and cannot be removed.
1227 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1230 var hasChildren bool
1232 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1234 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1236 xcheckf(ctx, err, "deleting mailbox")
1239 store.BroadcastChanges(acc, changes)
1242 for _, mID := range removeMessageIDs {
1243 p := acc.MessagePath(mID)
1245 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1249// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1250// its child mailboxes.
1251func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1252 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1253 acc := reqInfo.Account
1256 var expunged []store.Message
1258 acc.WithWLock(func() {
1259 var changes []store.Change
1261 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1262 mb := xmailboxID(ctx, tx, mailboxID)
1264 modseq, err := acc.NextModSeq(tx)
1265 xcheckf(ctx, err, "next modseq")
1267 // Mark messages as expunged.
1268 qm := bstore.QueryTx[store.Message](tx)
1269 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1270 qm.FilterEqual("Expunged", false)
1272 qm.Gather(&expunged)
1273 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1274 xcheckf(ctx, err, "deleting messages")
1276 // Remove Recipients.
1277 anyIDs := make([]any, len(expunged))
1278 for i, m := range expunged {
1281 qmr := bstore.QueryTx[store.Recipient](tx)
1282 qmr.FilterEqual("MessageID", anyIDs...)
1283 _, err = qmr.Delete()
1284 xcheckf(ctx, err, "removing message recipients")
1286 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1288 uids := make([]store.UID, len(expunged))
1289 for i, m := range expunged {
1290 m.Expunged = false // Gather returns updated values.
1291 mb.Sub(m.MailboxCounts())
1295 expunged[i].Junk = false
1296 expunged[i].Notjunk = false
1299 err = tx.Update(&mb)
1300 xcheckf(ctx, err, "updating mailbox for counts")
1302 err = acc.AddMessageSize(log, tx, -totalSize)
1303 xcheckf(ctx, err, "updating disk usage")
1305 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1306 xcheckf(ctx, err, "retraining expunged messages")
1308 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1309 changes = []store.Change{chremove, mb.ChangeCounts()}
1312 store.BroadcastChanges(acc, changes)
1315 for _, m := range expunged {
1316 p := acc.MessagePath(m.ID)
1318 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1322// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1323// ID and its messages are unchanged.
1324func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1325 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1326 acc := reqInfo.Account
1328 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1329 // standard. We can just say no.
1331 newName, _, err = store.CheckMailboxName(newName, false)
1332 xcheckuserf(ctx, err, "checking new mailbox name")
1334 acc.WithWLock(func() {
1335 var changes []store.Change
1337 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1338 mbsrc := xmailboxID(ctx, tx, mailboxID)
1340 var isInbox, notExists, alreadyExists bool
1341 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1342 if isInbox || notExists || alreadyExists {
1343 xcheckuserf(ctx, err, "renaming mailbox")
1345 xcheckf(ctx, err, "renaming mailbox")
1348 store.BroadcastChanges(acc, changes)
1352// CompleteRecipient returns autocomplete matches for a recipient, returning the
1353// matches, most recently used first, and whether this is the full list and further
1354// requests for longer prefixes aren't necessary.
1355func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1356 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1357 acc := reqInfo.Account
1359 search = strings.ToLower(search)
1361 var matches []string
1363 acc.WithRLock(func() {
1364 xdbread(ctx, acc, func(tx *bstore.Tx) {
1369 seen := map[key]bool{}
1371 q := bstore.QueryTx[store.Recipient](tx)
1373 err := q.ForEach(func(r store.Recipient) error {
1374 k := key{r.Localpart, r.Domain}
1378 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1379 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1380 if !strings.Contains(strings.ToLower(address), search) {
1383 if len(matches) >= 20 {
1385 return bstore.StopForEach
1388 // Look in the message that was sent for a name along with the address.
1389 m := store.Message{ID: r.MessageID}
1391 xcheckf(ctx, err, "get sent message")
1392 if !m.Expunged && m.ParsedBuf != nil {
1393 var part message.Part
1394 err := json.Unmarshal(m.ParsedBuf, &part)
1395 xcheckf(ctx, err, "parsing part")
1397 dom, err := dns.ParseDomain(r.Domain)
1398 xcheckf(ctx, err, "parsing domain of recipient")
1402 checkAddrs := func(l []message.Address) {
1406 for _, a := range l {
1407 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1409 address = addressString(a, false)
1414 if part.Envelope != nil {
1415 env := part.Envelope
1422 matches = append(matches, address)
1426 xcheckf(ctx, err, "listing recipients")
1432// addressString returns an address into a string as it could be used in a message header.
1433func addressString(a message.Address, smtputf8 bool) string {
1435 dom, err := dns.ParseDomain(a.Host)
1437 if smtputf8 && dom.Unicode != "" {
1443 s := "<" + a.User + "@" + host + ">"
1445 // todo: properly encoded/escaped name
1446 s = a.Name + " " + s
1451// MailboxSetSpecialUse sets the special use flags of a mailbox.
1452func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1453 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1454 acc := reqInfo.Account
1456 acc.WithWLock(func() {
1457 var changes []store.Change
1459 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1460 xmb := xmailboxID(ctx, tx, mb.ID)
1462 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1463 // we set, we clear it for the mailbox(es) that had it, if any.
1464 clearPrevious := func(clear bool, specialUse string) {
1468 var ombl []store.Mailbox
1469 q := bstore.QueryTx[store.Mailbox](tx)
1470 q.FilterNotEqual("ID", mb.ID)
1471 q.FilterEqual(specialUse, true)
1473 _, err := q.UpdateField(specialUse, false)
1474 xcheckf(ctx, err, "updating previous special-use mailboxes")
1476 for _, omb := range ombl {
1477 changes = append(changes, omb.ChangeSpecialUse())
1480 clearPrevious(mb.Archive, "Archive")
1481 clearPrevious(mb.Draft, "Draft")
1482 clearPrevious(mb.Junk, "Junk")
1483 clearPrevious(mb.Sent, "Sent")
1484 clearPrevious(mb.Trash, "Trash")
1486 xmb.SpecialUse = mb.SpecialUse
1487 err := tx.Update(&xmb)
1488 xcheckf(ctx, err, "updating special-use flags for mailbox")
1489 changes = append(changes, xmb.ChangeSpecialUse())
1492 store.BroadcastChanges(acc, changes)
1496// ThreadCollapse saves the ThreadCollapse field for the messages and its
1497// children. The messageIDs are typically thread roots. But not all roots
1498// (without parent) of a thread need to have the same collapsed state.
1499func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1500 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1501 acc := reqInfo.Account
1503 if len(messageIDs) == 0 {
1504 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1507 acc.WithWLock(func() {
1508 changes := make([]store.Change, 0, len(messageIDs))
1509 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1510 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1511 // (child) messages. Further refined in FilterFn.
1512 threadIDs := map[int64]struct{}{}
1513 msgIDs := map[int64]struct{}{}
1514 for _, id := range messageIDs {
1515 m := store.Message{ID: id}
1517 if err == bstore.ErrAbsent {
1518 xcheckuserf(ctx, err, "get message")
1520 xcheckf(ctx, err, "get message")
1521 threadIDs[m.ThreadID] = struct{}{}
1522 msgIDs[id] = struct{}{}
1525 var updated []store.Message
1526 q := bstore.QueryTx[store.Message](tx)
1527 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1528 q.FilterNotEqual("ThreadCollapsed", collapse)
1529 q.FilterFn(func(tm store.Message) bool {
1530 for _, id := range tm.ThreadParentIDs {
1531 if _, ok := msgIDs[id]; ok {
1535 _, ok := msgIDs[tm.ID]
1539 q.SortAsc("ID") // Consistent order for testing.
1540 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1541 xcheckf(ctx, err, "updating collapse in database")
1543 for _, m := range updated {
1544 changes = append(changes, m.ChangeThread())
1547 store.BroadcastChanges(acc, changes)
1551// ThreadMute saves the ThreadMute field for the messages and their children.
1552// If messages are muted, they are also marked collapsed.
1553func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1554 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1555 acc := reqInfo.Account
1557 if len(messageIDs) == 0 {
1558 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1561 acc.WithWLock(func() {
1562 changes := make([]store.Change, 0, len(messageIDs))
1563 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1564 threadIDs := map[int64]struct{}{}
1565 msgIDs := map[int64]struct{}{}
1566 for _, id := range messageIDs {
1567 m := store.Message{ID: id}
1569 if err == bstore.ErrAbsent {
1570 xcheckuserf(ctx, err, "get message")
1572 xcheckf(ctx, err, "get message")
1573 threadIDs[m.ThreadID] = struct{}{}
1574 msgIDs[id] = struct{}{}
1577 var updated []store.Message
1579 q := bstore.QueryTx[store.Message](tx)
1580 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1581 q.FilterFn(func(tm store.Message) bool {
1582 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1585 for _, id := range tm.ThreadParentIDs {
1586 if _, ok := msgIDs[id]; ok {
1590 _, ok := msgIDs[tm.ID]
1594 fields := map[string]any{"ThreadMuted": mute}
1596 fields["ThreadCollapsed"] = true
1598 _, err := q.UpdateFields(fields)
1599 xcheckf(ctx, err, "updating mute in database")
1601 for _, m := range updated {
1602 changes = append(changes, m.ChangeThread())
1605 store.BroadcastChanges(acc, changes)
1609// SecurityResult indicates whether a security feature is supported.
1610type SecurityResult string
1613 SecurityResultError SecurityResult = "error"
1614 SecurityResultNo SecurityResult = "no"
1615 SecurityResultYes SecurityResult = "yes"
1616 // Unknown whether supported. Finding out may only be (reasonably) possible when
1617 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1619 SecurityResultUnknown SecurityResult = "unknown"
1622// RecipientSecurity is a quick analysis of the security properties of delivery to
1623// the recipient (domain).
1624type RecipientSecurity struct {
1625 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1626 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1628 STARTTLS SecurityResult
1630 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1632 MTASTS SecurityResult
1634 // Whether MX lookup response was DNSSEC-signed.
1635 DNSSEC SecurityResult
1637 // Whether first delivery destination has DANE records.
1640 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1641 // Will be "unknown" if no delivery to the domain has been attempted yet.
1642 RequireTLS SecurityResult
1645// RecipientSecurity looks up security properties of the address in the
1646// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1647func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1648 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1651 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1652 return recipientSecurity(ctx, log, resolver, messageAddressee)
1655// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1656func logPanic(ctx context.Context) {
1661 log := pkglog.WithContext(ctx)
1662 log.Error("recover from panic", slog.Any("panic", x))
1664 metrics.PanicInc(metrics.Webmail)
1667// separate function for testing with mocked resolver.
1668func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1669 rs := RecipientSecurity{
1670 SecurityResultUnknown,
1671 SecurityResultUnknown,
1672 SecurityResultUnknown,
1673 SecurityResultUnknown,
1674 SecurityResultUnknown,
1677 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1678 msgAddr, err := parser.Parse(messageAddressee)
1680 return rs, fmt.Errorf("parsing addressee: %v", err)
1682 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1684 return rs, fmt.Errorf("parsing address: %v", err)
1687 var wg sync.WaitGroup
1695 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1696 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1697 rs.MTASTS = SecurityResultYes
1698 } else if err == nil {
1699 rs.MTASTS = SecurityResultNo
1701 rs.MTASTS = SecurityResultError
1711 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1713 rs.DNSSEC = SecurityResultError
1716 if origNextHopAuthentic && expandedNextHopAuthentic {
1717 rs.DNSSEC = SecurityResultYes
1719 rs.DNSSEC = SecurityResultNo
1722 if !origNextHopAuthentic {
1723 rs.DANE = SecurityResultNo
1727 // We're only looking at the first host to deliver to (typically first mx destination).
1728 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1729 return // Should not happen.
1733 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1734 // error result instead of no-DANE result.
1735 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1737 rs.DANE = SecurityResultError
1741 rs.DANE = SecurityResultNo
1745 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1747 rs.DANE = SecurityResultError
1749 } else if daneRequired {
1750 rs.DANE = SecurityResultYes
1752 rs.DANE = SecurityResultNo
1756 // STARTTLS and RequireTLS
1757 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1758 acc := reqInfo.Account
1760 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1761 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1762 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1764 if err == bstore.ErrAbsent {
1766 } else if err != nil {
1767 rs.STARTTLS = SecurityResultError
1768 rs.RequireTLS = SecurityResultError
1769 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1773 rs.STARTTLS = SecurityResultYes
1775 rs.STARTTLS = SecurityResultNo
1778 rs.RequireTLS = SecurityResultYes
1780 rs.RequireTLS = SecurityResultNo
1784 xcheckf(ctx, err, "lookup recipient domain")
1791// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1792func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1793 s, err := wordDecoder.DecodeHeader(text)
1794 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1798// SettingsSave saves settings, e.g. for composing.
1799func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1800 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1801 acc := reqInfo.Account
1804 err := acc.DB.Update(ctx, &settings)
1805 xcheckf(ctx, err, "save settings")
1808func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1809 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1810 acc := reqInfo.Account
1813 xdbread(ctx, acc, func(tx *bstore.Tx) {
1814 m := xmessageID(ctx, tx, msgID)
1815 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1816 mbDst := xmailboxID(ctx, tx, mbDstID)
1818 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1821 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1823 conf, _ := acc.Conf()
1824 dest := conf.Destinations[rcptTo] // May not be present.
1825 defaultMailbox := "Inbox"
1826 if dest.Mailbox != "" {
1827 defaultMailbox = dest.Mailbox
1830 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1831 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1835 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1836 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1837 xcheckf(ctx, err, "looking up previous response for source mailbox")
1841 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1842 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1847 // Parse message for List-Id header.
1848 state := msgState{acc: acc}
1850 pm, err := parsedMessage(log, m, &state, true, false)
1851 xcheckf(ctx, err, "parsing message")
1853 // The suggested ruleset. Once all is checked, we'll return it.
1854 var nrs *config.Ruleset
1856 // If List-Id header is present, we'll treat it as a (mailing) list message.
1857 if l, ok := pm.Headers["List-Id"]; ok {
1859 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1862 var listIDDom dns.Domain
1863 listID, listIDDom = parseListID(l[0])
1865 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1869 // Check if we have a previous "No" answer for this list-id.
1870 no := store.RulesetNoListID{
1871 RcptToAddress: rcptTo,
1873 ToInbox: mbDst.Name == "Inbox",
1875 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1876 xcheckf(ctx, err, "looking up previous response for list-id")
1881 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1882 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1883 // are subscribed to, and take advantage of any reduced junk filtering.
1884 listIDDomStr := listIDDom.Name()
1886 doms := m.DKIMDomains
1887 if m.MailFromValidated {
1888 doms = append(doms, m.MailFromDomain)
1890 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1891 // of SPF verification of one host.
1892 sort.Slice(doms, func(i, j int) bool {
1893 return len(doms[i]) < len(doms[j])
1895 var listAllowDom string
1896 for _, dom := range doms {
1897 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1902 if listAllowDom == "" {
1906 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1907 nrs = &config.Ruleset{
1908 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1909 ListAllowDomain: listAllowDom,
1910 Mailbox: mbDst.Name,
1913 // Otherwise, try to make a rule based on message "From" address.
1914 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1917 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1919 no := store.RulesetNoMsgFrom{
1920 RcptToAddress: rcptTo,
1921 MsgFromAddress: msgFrom,
1922 ToInbox: mbDst.Name == "Inbox",
1924 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1925 xcheckf(ctx, err, "looking up previous response for message from address")
1930 nrs = &config.Ruleset{
1931 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1932 Mailbox: mbDst.Name,
1936 // Only suggest adding/removing rule if it isn't/is present.
1938 for _, rs := range dest.Rulesets {
1939 xrs := config.Ruleset{
1940 MsgFromRegexp: rs.MsgFromRegexp,
1941 HeadersRegexp: rs.HeadersRegexp,
1942 ListAllowDomain: rs.ListAllowDomain,
1943 Mailbox: nrs.Mailbox,
1945 if xrs.Equal(*nrs) {
1950 isRemove = mbDst.Name == defaultMailbox
1952 nrs.Mailbox = mbSrc.Name
1954 if isRemove && !have || !isRemove && have {
1958 // We'll be returning a suggested ruleset.
1959 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1965// Parse the list-id value (the value between <>) from a list-id header.
1966// Returns an empty string if it couldn't be parsed.
1967func parseListID(s string) (listID string, dom dns.Domain) {
1969 s = strings.TrimRight(s, " \t")
1970 if !strings.HasSuffix(s, ">") {
1971 return "", dns.Domain{}
1974 t := strings.Split(s, "<")
1976 return "", dns.Domain{}
1979 dom, err := dns.ParseDomain(s)
1986func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1987 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1989 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1990 dest, ok := acc.Destinations[rcptTo]
1992 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1993 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")
1996 nd := map[string]config.Destination{}
1997 for addr, d := range acc.Destinations {
2000 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2002 acc.Destinations = nd
2004 xcheckf(ctx, err, "saving account with new ruleset")
2007func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2008 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2010 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2011 dest, ok := acc.Destinations[rcptTo]
2013 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2016 nd := map[string]config.Destination{}
2017 for addr, d := range acc.Destinations {
2020 var l []config.Ruleset
2022 for _, rs := range dest.Rulesets {
2023 if rs.Equal(ruleset) {
2030 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2034 acc.Destinations = nd
2036 xcheckf(ctx, err, "saving account with new ruleset")
2039func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2040 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2041 acc := reqInfo.Account
2045 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2047 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2049 xcheckf(ctx, err, "storing user response")
2052func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2053 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2054 acc := reqInfo.Account
2056 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2057 xcheckf(ctx, err, "storing user response")
2060func slicesAny[T any](l []T) []any {
2061 r := make([]any, len(l))
2062 for i, v := range l {
2068// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2069func (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) {