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 token to use for an SSE connection. A token can only be used for
134// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
135// with at most 10 unused tokens (the most recently created) per account.
136func (Webmail) Token(ctx context.Context) string {
137 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
138 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
141// Requests sends a new request for an open SSE connection. Any currently active
142// request for the connection will be canceled, but this is done asynchrously, so
143// the SSE connection may still send results for the previous request. Callers
144// should take care to ignore such results. If req.Cancel is set, no new request is
146func (Webmail) Request(ctx context.Context, req Request) {
147 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
149 if !req.Cancel && req.Page.Count <= 0 {
150 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
153 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
155 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
160// ParsedMessage returns enough to render the textual body of a message. It is
161// assumed the client already has other fields through MessageItem.
162func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
163 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
165 acc := reqInfo.Account
167 xdbread(ctx, acc, func(tx *bstore.Tx) {
168 m := xmessageID(ctx, tx, msgID)
170 state := msgState{acc: acc}
173 pm, err = parsedMessage(log, m, &state, true, false)
174 xcheckf(ctx, err, "parsing message")
176 if len(pm.envelope.From) == 1 {
177 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
178 xcheckf(ctx, err, "looking up view mode for from address")
184// fromAddrViewMode returns the view mode for a from address.
185func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
186 lp, err := smtp.ParseLocalpart(from.User)
188 return store.ModeDefault, nil
190 fromAddr := smtp.Address{Localpart: lp, Domain: from.Domain}.Pack(true)
191 fas := store.FromAddressSettings{FromAddress: fromAddr}
193 if err == bstore.ErrAbsent {
194 return store.ModeDefault, nil
196 return fas.ViewMode, err
199// FromAddressSettingsSave saves per-"From"-address settings.
200func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
201 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
202 acc := reqInfo.Account
204 if fas.FromAddress == "" {
205 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
208 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
209 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
210 err := tx.Update(&fas)
211 xcheckf(ctx, err, "updating settings for from address")
213 err := tx.Insert(&fas)
214 xcheckf(ctx, err, "inserting settings for from address")
219// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
220// of the message in storage. Used when opening a previously saved draft message
222// If no message is find, zero is returned, not an error.
223func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
224 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
225 acc := reqInfo.Account
227 messageID, _, _ = message.MessageIDCanonical(messageID)
229 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
232 xdbread(ctx, acc, func(tx *bstore.Tx) {
233 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
234 if err == bstore.ErrAbsent {
237 xcheckf(ctx, err, "looking up message by message-id")
243// ComposeMessage is a message to be composed, for saving draft messages.
244type ComposeMessage struct {
249 ReplyTo string // If non-empty, Reply-To header to add to message.
252 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
253 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
256// MessageCompose composes a message and saves it to the mailbox. Used for
257// saving draft messages.
258func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
259 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
260 acc := reqInfo.Account
263 log.Debug("message compose")
265 // Prevent any accidental control characters, or attempts at getting bare \r or \n
267 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
268 for _, s := range l {
269 for _, c := range s {
271 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
277 fromAddr, err := parseAddress(m.From)
278 xcheckuserf(ctx, err, "parsing From address")
280 var replyTo *message.NameAddress
282 addr, err := parseAddress(m.ReplyTo)
283 xcheckuserf(ctx, err, "parsing Reply-To address")
287 var recipients []smtp.Address
289 var toAddrs []message.NameAddress
290 for _, s := range m.To {
291 addr, err := parseAddress(s)
292 xcheckuserf(ctx, err, "parsing To address")
293 toAddrs = append(toAddrs, addr)
294 recipients = append(recipients, addr.Address)
297 var ccAddrs []message.NameAddress
298 for _, s := range m.Cc {
299 addr, err := parseAddress(s)
300 xcheckuserf(ctx, err, "parsing Cc address")
301 ccAddrs = append(ccAddrs, addr)
302 recipients = append(recipients, addr.Address)
305 var bccAddrs []message.NameAddress
306 for _, s := range m.Bcc {
307 addr, err := parseAddress(s)
308 xcheckuserf(ctx, err, "parsing Bcc address")
309 bccAddrs = append(bccAddrs, addr)
310 recipients = append(recipients, addr.Address)
313 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
315 for _, a := range recipients {
316 if a.Localpart.IsInternational() {
321 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
322 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
325 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
329 // Create file to compose message into.
330 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
331 xcheckf(ctx, err, "creating temporary file for compose message")
332 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
334 // If writing to the message file fails, we abort immediately.
335 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
341 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
342 xcheckuserf(ctx, err, "making message")
343 } else if ok && errors.Is(err, message.ErrCompose) {
344 xcheckf(ctx, err, "making message")
349 // Outer message headers.
350 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
352 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
354 xc.HeaderAddrs("To", toAddrs)
355 xc.HeaderAddrs("Cc", ccAddrs)
356 xc.HeaderAddrs("Bcc", bccAddrs)
358 xc.Subject(m.Subject)
361 // Add In-Reply-To and References headers.
362 if m.ResponseMessageID > 0 {
363 xdbread(ctx, acc, func(tx *bstore.Tx) {
364 rm := xmessageID(ctx, tx, m.ResponseMessageID)
365 msgr := acc.MessageReader(rm)
368 log.Check(err, "closing message reader")
370 rp, err := rm.LoadPart(msgr)
371 xcheckf(ctx, err, "load parsed message")
372 h, err := rp.Header()
373 xcheckf(ctx, err, "parsing header")
375 if rp.Envelope == nil {
379 if rp.Envelope.MessageID != "" {
380 xc.Header("In-Reply-To", rp.Envelope.MessageID)
382 refs := h.Values("References")
383 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
384 refs = []string{rp.Envelope.InReplyTo}
386 if rp.Envelope.MessageID != "" {
387 refs = append(refs, rp.Envelope.MessageID)
390 xc.Header("References", strings.Join(refs, "\r\n\t"))
394 xc.Header("MIME-Version", "1.0")
395 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
396 xc.Header("Content-Type", ct)
397 xc.Header("Content-Transfer-Encoding", cte)
399 xc.Write([]byte(textBody))
404 // Remove previous draft message, append message to destination mailbox.
405 acc.WithRLock(func() {
406 var changes []store.Change
408 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
409 var modseq store.ModSeq // Only set if needed.
411 if m.DraftMessageID > 0 {
412 var nchanges []store.Change
413 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
414 changes = append(changes, nchanges...)
415 // On-disk file is removed after lock.
418 // Find mailbox to write to.
419 mb := store.Mailbox{ID: mailboxID}
421 if err == bstore.ErrAbsent {
422 xcheckuserf(ctx, err, "looking up mailbox")
424 xcheckf(ctx, err, "looking up mailbox")
427 modseq, err = acc.NextModSeq(tx)
428 xcheckf(ctx, err, "next modseq")
435 MailboxOrigID: mb.ID,
436 Flags: store.Flags{Notjunk: true},
440 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
441 xcheckf(ctx, err, "checking quota")
443 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
446 // Update mailbox before delivery, which changes uidnext.
447 mb.Add(nm.MailboxCounts())
449 xcheckf(ctx, err, "updating sent mailbox for counts")
451 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
452 xcheckf(ctx, err, "storing message in mailbox")
454 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
457 store.BroadcastChanges(acc, changes)
460 // Remove on-disk file for removed draft message.
461 if m.DraftMessageID > 0 {
462 p := acc.MessagePath(m.DraftMessageID)
464 log.Check(err, "removing draft message file")
470// Attachment is a MIME part is an existing message that is not intended as
471// viewable text or HTML part.
472type Attachment struct {
473 Path []int // Indices into top-level message.Part.Parts.
475 // File name based on "name" attribute of "Content-Type", or the "filename"
476 // attribute of "Content-Disposition".
482// SubmitMessage is an email message to be sent to one or more recipients.
483// Addresses are formatted as just email address, or with a name like "name
485type SubmitMessage struct {
490 ReplyTo string // If non-empty, Reply-To header to add to message.
494 ForwardAttachments ForwardAttachments
496 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
497 UserAgent string // User-Agent header added if not empty.
498 RequireTLS *bool // For "Require TLS" extension during delivery.
499 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
500 ArchiveThread bool // If set, thread is archived after sending message.
501 DraftMessageID int64 // If set, draft message that will be removed after sending.
504// ForwardAttachments references attachments by a list of message.Part paths.
505type ForwardAttachments struct {
506 MessageID int64 // Only relevant if MessageID is not 0.
507 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
510// File is a new attachment (not from an existing message that is being
511// forwarded) to send with a SubmitMessage.
514 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
517// parseAddress expects either a plain email address like "user@domain", or a
518// single address as used in a message header, like "name <user@domain>".
519func parseAddress(msghdr string) (message.NameAddress, error) {
520 a, err := mail.ParseAddress(msghdr)
522 return message.NameAddress{}, err
526 path, err := smtp.ParseAddress(a.Address)
528 return message.NameAddress{}, err
530 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
533func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
535 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
537 mb := store.Mailbox{ID: mailboxID}
539 if err == bstore.ErrAbsent {
540 xcheckuserf(ctx, err, "getting mailbox")
542 xcheckf(ctx, err, "getting mailbox")
546// xmessageID returns a non-expunged message or panics with a sherpa error.
547func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
549 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
551 m := store.Message{ID: messageID}
553 if err == bstore.ErrAbsent {
554 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
555 } else if err == nil && m.Expunged {
556 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
558 xcheckf(ctx, err, "getting message")
562func xrandomID(ctx context.Context, n int) string {
563 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
566func xrandom(ctx context.Context, n int) []byte {
567 buf := make([]byte, n)
568 x, err := cryptorand.Read(buf)
569 xcheckf(ctx, err, "read random")
571 xcheckf(ctx, errors.New("short random read"), "read random")
576// MessageSubmit sends a message by submitting it the outgoing email queue. The
577// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
578// Bcc message header.
580// If a Sent mailbox is configured, messages are added to it after submitting
581// to the delivery queue. If Bcc addresses were present, a header is prepended
582// to the message stored in the Sent mailbox.
583func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
584 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
585 acc := reqInfo.Account
588 log.Debug("message submit")
590 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
592 // 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.
594 // Prevent any accidental control characters, or attempts at getting bare \r or \n
596 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
597 for _, s := range l {
598 for _, c := range s {
600 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
606 fromAddr, err := parseAddress(m.From)
607 xcheckuserf(ctx, err, "parsing From address")
609 var replyTo *message.NameAddress
611 a, err := parseAddress(m.ReplyTo)
612 xcheckuserf(ctx, err, "parsing Reply-To address")
616 var recipients []smtp.Address
618 var toAddrs []message.NameAddress
619 for _, s := range m.To {
620 addr, err := parseAddress(s)
621 xcheckuserf(ctx, err, "parsing To address")
622 toAddrs = append(toAddrs, addr)
623 recipients = append(recipients, addr.Address)
626 var ccAddrs []message.NameAddress
627 for _, s := range m.Cc {
628 addr, err := parseAddress(s)
629 xcheckuserf(ctx, err, "parsing Cc address")
630 ccAddrs = append(ccAddrs, addr)
631 recipients = append(recipients, addr.Address)
634 var bccAddrs []message.NameAddress
635 for _, s := range m.Bcc {
636 addr, err := parseAddress(s)
637 xcheckuserf(ctx, err, "parsing Bcc address")
638 bccAddrs = append(bccAddrs, addr)
639 recipients = append(recipients, addr.Address)
642 // Check if from address is allowed for account.
643 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
644 metricSubmission.WithLabelValues("badfrom").Inc()
645 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
648 if len(recipients) == 0 {
649 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
652 // Check outgoing message rate limit.
653 xdbread(ctx, acc, func(tx *bstore.Tx) {
654 rcpts := make([]smtp.Path, len(recipients))
655 for i, r := range recipients {
656 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
658 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
660 metricSubmission.WithLabelValues("messagelimiterror").Inc()
661 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
662 } else if rcptlimit >= 0 {
663 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
664 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
666 xcheckf(ctx, err, "checking send limit")
669 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
671 for _, a := range recipients {
672 if a.Localpart.IsInternational() {
677 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
678 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
681 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
685 // Create file to compose message into.
686 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
687 xcheckf(ctx, err, "creating temporary file for message")
688 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
690 // If writing to the message file fails, we abort immediately.
691 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
697 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
698 xcheckuserf(ctx, err, "making message")
699 } else if ok && errors.Is(err, message.ErrCompose) {
700 xcheckf(ctx, err, "making message")
705 // 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
707 // Each queued message gets a Received header.
708 // We don't have access to the local IP for adding.
709 // We cannot use VIA, because there is no registered method. We would like to use
710 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
711 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
712 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
713 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
714 recvHdrFor := func(rcptTo string) string {
715 recvHdr := &message.HeaderWriter{}
716 // For additional Received-header clauses, see:
717 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
718 // Note: we don't have "via" or "with", there is no registered for webmail.
719 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
720 if reqInfo.Request.TLS != nil {
721 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
723 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
724 return recvHdr.String()
727 // Outer message headers.
728 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
730 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
732 xc.HeaderAddrs("To", toAddrs)
733 xc.HeaderAddrs("Cc", ccAddrs)
734 // We prepend Bcc headers to the message when adding to the Sent mailbox.
736 xc.Subject(m.Subject)
739 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
740 xc.Header("Message-Id", messageID)
741 xc.Header("Date", time.Now().Format(message.RFC5322Z))
742 // Add In-Reply-To and References headers.
743 if m.ResponseMessageID > 0 {
744 xdbread(ctx, acc, func(tx *bstore.Tx) {
745 rm := xmessageID(ctx, tx, m.ResponseMessageID)
746 msgr := acc.MessageReader(rm)
749 log.Check(err, "closing message reader")
751 rp, err := rm.LoadPart(msgr)
752 xcheckf(ctx, err, "load parsed message")
753 h, err := rp.Header()
754 xcheckf(ctx, err, "parsing header")
756 if rp.Envelope == nil {
760 if rp.Envelope.MessageID != "" {
761 xc.Header("In-Reply-To", rp.Envelope.MessageID)
763 refs := h.Values("References")
764 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
765 refs = []string{rp.Envelope.InReplyTo}
767 if rp.Envelope.MessageID != "" {
768 refs = append(refs, rp.Envelope.MessageID)
771 xc.Header("References", strings.Join(refs, "\r\n\t"))
775 if m.UserAgent != "" {
776 xc.Header("User-Agent", m.UserAgent)
778 if m.RequireTLS != nil && !*m.RequireTLS {
779 xc.Header("TLS-Required", "No")
781 xc.Header("MIME-Version", "1.0")
783 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
784 mp := multipart.NewWriter(xc)
785 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
788 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
789 textHdr := textproto.MIMEHeader{}
790 textHdr.Set("Content-Type", ct)
791 textHdr.Set("Content-Transfer-Encoding", cte)
793 textp, err := mp.CreatePart(textHdr)
794 xcheckf(ctx, err, "adding text part to message")
795 _, err = textp.Write(textBody)
796 xcheckf(ctx, err, "writing text part")
798 xaddPart := func(ct, filename string) io.Writer {
799 ahdr := textproto.MIMEHeader{}
800 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
802 ahdr.Set("Content-Type", ct)
803 ahdr.Set("Content-Transfer-Encoding", "base64")
804 ahdr.Set("Content-Disposition", cd)
805 ap, err := mp.CreatePart(ahdr)
806 xcheckf(ctx, err, "adding attachment part to message")
810 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
811 ap := xaddPart(ct, filename)
813 for len(base64Data) > 0 {
819 line, base64Data = base64Data[:n], base64Data[n:]
820 _, err := ap.Write(line)
821 xcheckf(ctx, err, "writing attachment")
822 _, err = ap.Write([]byte("\r\n"))
823 xcheckf(ctx, err, "writing attachment")
827 xaddAttachment := func(ct, filename string, r io.Reader) {
828 ap := xaddPart(ct, filename)
829 wc := moxio.Base64Writer(ap)
830 _, err := io.Copy(wc, r)
831 xcheckf(ctx, err, "adding attachment")
833 xcheckf(ctx, err, "flushing attachment")
836 for _, a := range m.Attachments {
838 if !strings.HasPrefix(s, "data:") {
839 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
842 t := strings.SplitN(s, ",", 2)
844 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
846 if !strings.HasSuffix(t[0], "base64") {
847 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
849 ct := strings.TrimSuffix(t[0], "base64")
850 ct = strings.TrimSuffix(ct, ";")
852 ct = "application/octet-stream"
854 filename := a.Filename
856 filename = "unnamed.bin"
858 params := map[string]string{"name": filename}
859 ct = mime.FormatMediaType(ct, params)
861 // Ensure base64 is valid, then we'll write the original string.
862 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
863 xcheckuserf(ctx, err, "parsing attachment as base64")
865 xaddAttachmentBase64(ct, filename, []byte(t[1]))
868 if len(m.ForwardAttachments.Paths) > 0 {
869 acc.WithRLock(func() {
870 xdbread(ctx, acc, func(tx *bstore.Tx) {
871 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
872 msgr := acc.MessageReader(fm)
875 log.Check(err, "closing message reader")
878 fp, err := fm.LoadPart(msgr)
879 xcheckf(ctx, err, "load parsed message")
881 for _, path := range m.ForwardAttachments.Paths {
883 for _, xp := range path {
884 if xp < 0 || xp >= len(ap.Parts) {
885 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
890 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
892 filename = "unnamed.bin"
894 params := map[string]string{"name": filename}
895 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
896 params["charset"] = pcharset
898 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
899 ct = mime.FormatMediaType(ct, params)
900 xaddAttachment(ct, filename, ap.Reader())
907 xcheckf(ctx, err, "writing mime multipart")
909 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
910 xc.Header("Content-Type", ct)
911 xc.Header("Content-Transfer-Encoding", cte)
913 xc.Write([]byte(textBody))
918 // Add DKIM-Signature headers.
920 fd := fromAddr.Address.Domain
921 confDom, _ := mox.Conf.Domain(fd)
922 selectors := mox.DKIMSelectors(confDom.DKIM)
923 if len(selectors) > 0 {
924 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
926 metricServerErrors.WithLabelValues("dkimsign").Inc()
928 xcheckf(ctx, err, "sign dkim")
930 msgPrefix = dkimHeaders
933 accConf, _ := acc.Conf()
934 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
935 xcheckf(ctx, err, "parsing login address")
936 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
937 fromPath := fromAddr.Address.Path()
938 var localpartBase string
940 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
942 qml := make([]queue.Msg, len(recipients))
944 for i, rcpt := range recipients {
948 fromID = xrandomID(ctx, 16)
949 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
952 // Don't use per-recipient unique message prefix when multiple recipients are
953 // present, or the queue cannot deliver it in a single smtp transaction.
955 if len(recipients) == 1 {
956 recvRcpt = rcpt.Pack(smtputf8)
958 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
959 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
961 Localpart: rcpt.Localpart,
962 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
964 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
965 if m.FutureRelease != nil {
966 ival := time.Until(*m.FutureRelease)
968 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
969 } else if ival > queue.FutureReleaseIntervalMax {
970 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
972 qm.NextAttempt = *m.FutureRelease
973 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
974 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
977 // no qm.Extra from webmail
980 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
982 metricSubmission.WithLabelValues("queueerror").Inc()
984 xcheckf(ctx, err, "adding messages to the delivery queue")
985 metricSubmission.WithLabelValues("ok").Inc()
987 var modseq store.ModSeq // Only set if needed.
989 // Append message to Sent mailbox, mark original messages as answered/forwarded,
990 // remove any draft message.
991 acc.WithRLock(func() {
992 var changes []store.Change
996 if x := recover(); x != nil {
998 metricServerErrors.WithLabelValues("submit").Inc()
1003 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1004 if m.DraftMessageID > 0 {
1005 var nchanges []store.Change
1006 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1007 changes = append(changes, nchanges...)
1008 // On-disk file is removed after lock.
1011 if m.ResponseMessageID > 0 {
1012 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1019 if !rm.Junk && !rm.Notjunk {
1022 if rm.Flags != oflags {
1023 modseq, err = acc.NextModSeq(tx)
1024 xcheckf(ctx, err, "next modseq")
1026 err := tx.Update(&rm)
1027 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1028 changes = append(changes, rm.ChangeFlags(oflags))
1030 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1031 xcheckf(ctx, err, "retraining messages after reply/forward")
1034 // Move messages from this thread still in this mailbox to the designated Archive
1036 if m.ArchiveThread {
1037 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1038 if err == bstore.ErrAbsent {
1039 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1041 xcheckf(ctx, err, "looking up designated archive mailbox")
1044 q := bstore.QueryTx[store.Message](tx)
1045 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID})
1046 q.FilterEqual("Expunged", false)
1047 err = q.IDs(&msgIDs)
1048 xcheckf(ctx, err, "listing messages in thread to archive")
1049 if len(msgIDs) > 0 {
1050 var nchanges []store.Change
1051 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1052 changes = append(changes, nchanges...)
1057 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1058 if err == bstore.ErrAbsent {
1059 // There is no mailbox designated as Sent mailbox, so we're done.
1062 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1065 modseq, err = acc.NextModSeq(tx)
1066 xcheckf(ctx, err, "next modseq")
1069 // If there were bcc headers, prepend those to the stored message only, before the
1070 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1071 // message won't validate with DKIM anymore, which is fine.
1072 if len(bccAddrs) > 0 {
1073 var sb strings.Builder
1074 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1075 xbcc.HeaderAddrs("Bcc", bccAddrs)
1077 msgPrefix = sb.String() + msgPrefix
1080 sentm := store.Message{
1083 MailboxID: sentmb.ID,
1084 MailboxOrigID: sentmb.ID,
1085 Flags: store.Flags{Notjunk: true, Seen: true},
1086 Size: int64(len(msgPrefix)) + xc.Size,
1087 MsgPrefix: []byte(msgPrefix),
1090 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1091 xcheckf(ctx, err, "checking quota")
1093 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1096 // Update mailbox before delivery, which changes uidnext.
1097 sentmb.Add(sentm.MailboxCounts())
1098 err = tx.Update(&sentmb)
1099 xcheckf(ctx, err, "updating sent mailbox for counts")
1101 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1103 metricSubmission.WithLabelValues("storesenterror").Inc()
1106 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1108 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1111 store.BroadcastChanges(acc, changes)
1114 // Remove on-disk file for removed draft message.
1115 if m.DraftMessageID > 0 {
1116 p := acc.MessagePath(m.DraftMessageID)
1118 log.Check(err, "removing draft message file")
1122// MessageMove moves messages to another mailbox. If the message is already in
1123// the mailbox an error is returned.
1124func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1125 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1126 acc := reqInfo.Account
1129 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1132var xops = webops.XOps{
1135 Checkuserf: xcheckuserf,
1138// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1139func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1140 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1141 acc := reqInfo.Account
1144 if len(messageIDs) == 0 {
1148 xops.MessageDelete(ctx, log, acc, messageIDs)
1151// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1152// flags should be lower-case, but will be converted and verified.
1153func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1154 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1155 acc := reqInfo.Account
1158 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1161// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1162func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1163 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1164 acc := reqInfo.Account
1167 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1170// MailboxCreate creates a new mailbox.
1171func (Webmail) MailboxCreate(ctx context.Context, name string) {
1172 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1173 acc := reqInfo.Account
1176 name, _, err = store.CheckMailboxName(name, false)
1177 xcheckuserf(ctx, err, "checking mailbox name")
1179 acc.WithWLock(func() {
1180 var changes []store.Change
1181 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1184 changes, _, exists, err = acc.MailboxCreate(tx, name)
1186 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1188 xcheckf(ctx, err, "creating mailbox")
1191 store.BroadcastChanges(acc, changes)
1195// MailboxDelete deletes a mailbox and all its messages.
1196func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1197 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1198 acc := reqInfo.Account
1201 // Messages to remove after having broadcasted the removal of messages.
1202 var removeMessageIDs []int64
1204 acc.WithWLock(func() {
1205 var changes []store.Change
1207 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1208 mb := xmailboxID(ctx, tx, mailboxID)
1209 if mb.Name == "Inbox" {
1210 // Inbox is special in IMAP and cannot be removed.
1211 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1214 var hasChildren bool
1216 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1218 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1220 xcheckf(ctx, err, "deleting mailbox")
1223 store.BroadcastChanges(acc, changes)
1226 for _, mID := range removeMessageIDs {
1227 p := acc.MessagePath(mID)
1229 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1233// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1234// its child mailboxes.
1235func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1236 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1237 acc := reqInfo.Account
1240 var expunged []store.Message
1242 acc.WithWLock(func() {
1243 var changes []store.Change
1245 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1246 mb := xmailboxID(ctx, tx, mailboxID)
1248 modseq, err := acc.NextModSeq(tx)
1249 xcheckf(ctx, err, "next modseq")
1251 // Mark messages as expunged.
1252 qm := bstore.QueryTx[store.Message](tx)
1253 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1254 qm.FilterEqual("Expunged", false)
1256 qm.Gather(&expunged)
1257 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1258 xcheckf(ctx, err, "deleting messages")
1260 // Remove Recipients.
1261 anyIDs := make([]any, len(expunged))
1262 for i, m := range expunged {
1265 qmr := bstore.QueryTx[store.Recipient](tx)
1266 qmr.FilterEqual("MessageID", anyIDs...)
1267 _, err = qmr.Delete()
1268 xcheckf(ctx, err, "removing message recipients")
1270 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1272 uids := make([]store.UID, len(expunged))
1273 for i, m := range expunged {
1274 m.Expunged = false // Gather returns updated values.
1275 mb.Sub(m.MailboxCounts())
1279 expunged[i].Junk = false
1280 expunged[i].Notjunk = false
1283 err = tx.Update(&mb)
1284 xcheckf(ctx, err, "updating mailbox for counts")
1286 err = acc.AddMessageSize(log, tx, -totalSize)
1287 xcheckf(ctx, err, "updating disk usage")
1289 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1290 xcheckf(ctx, err, "retraining expunged messages")
1292 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1293 changes = []store.Change{chremove, mb.ChangeCounts()}
1296 store.BroadcastChanges(acc, changes)
1299 for _, m := range expunged {
1300 p := acc.MessagePath(m.ID)
1302 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1306// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1307// ID and its messages are unchanged.
1308func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1309 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1310 acc := reqInfo.Account
1312 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1313 // standard. We can just say no.
1315 newName, _, err = store.CheckMailboxName(newName, false)
1316 xcheckuserf(ctx, err, "checking new mailbox name")
1318 acc.WithWLock(func() {
1319 var changes []store.Change
1321 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1322 mbsrc := xmailboxID(ctx, tx, mailboxID)
1324 var isInbox, notExists, alreadyExists bool
1325 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1326 if isInbox || notExists || alreadyExists {
1327 xcheckuserf(ctx, err, "renaming mailbox")
1329 xcheckf(ctx, err, "renaming mailbox")
1332 store.BroadcastChanges(acc, changes)
1336// CompleteRecipient returns autocomplete matches for a recipient, returning the
1337// matches, most recently used first, and whether this is the full list and further
1338// requests for longer prefixes aren't necessary.
1339func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1340 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1341 acc := reqInfo.Account
1343 search = strings.ToLower(search)
1345 var matches []string
1347 acc.WithRLock(func() {
1348 xdbread(ctx, acc, func(tx *bstore.Tx) {
1353 seen := map[key]bool{}
1355 q := bstore.QueryTx[store.Recipient](tx)
1357 err := q.ForEach(func(r store.Recipient) error {
1358 k := key{r.Localpart, r.Domain}
1362 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1363 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1364 if !strings.Contains(strings.ToLower(address), search) {
1367 if len(matches) >= 20 {
1369 return bstore.StopForEach
1372 // Look in the message that was sent for a name along with the address.
1373 m := store.Message{ID: r.MessageID}
1375 xcheckf(ctx, err, "get sent message")
1376 if !m.Expunged && m.ParsedBuf != nil {
1377 var part message.Part
1378 err := json.Unmarshal(m.ParsedBuf, &part)
1379 xcheckf(ctx, err, "parsing part")
1381 dom, err := dns.ParseDomain(r.Domain)
1382 xcheckf(ctx, err, "parsing domain of recipient")
1386 checkAddrs := func(l []message.Address) {
1390 for _, a := range l {
1391 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1393 address = addressString(a, false)
1398 if part.Envelope != nil {
1399 env := part.Envelope
1406 matches = append(matches, address)
1410 xcheckf(ctx, err, "listing recipients")
1416// addressString returns an address into a string as it could be used in a message header.
1417func addressString(a message.Address, smtputf8 bool) string {
1419 dom, err := dns.ParseDomain(a.Host)
1421 if smtputf8 && dom.Unicode != "" {
1427 s := "<" + a.User + "@" + host + ">"
1429 // todo: properly encoded/escaped name
1430 s = a.Name + " " + s
1435// MailboxSetSpecialUse sets the special use flags of a mailbox.
1436func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1437 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1438 acc := reqInfo.Account
1440 acc.WithWLock(func() {
1441 var changes []store.Change
1443 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1444 xmb := xmailboxID(ctx, tx, mb.ID)
1446 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1447 // we set, we clear it for the mailbox(es) that had it, if any.
1448 clearPrevious := func(clear bool, specialUse string) {
1452 var ombl []store.Mailbox
1453 q := bstore.QueryTx[store.Mailbox](tx)
1454 q.FilterNotEqual("ID", mb.ID)
1455 q.FilterEqual(specialUse, true)
1457 _, err := q.UpdateField(specialUse, false)
1458 xcheckf(ctx, err, "updating previous special-use mailboxes")
1460 for _, omb := range ombl {
1461 changes = append(changes, omb.ChangeSpecialUse())
1464 clearPrevious(mb.Archive, "Archive")
1465 clearPrevious(mb.Draft, "Draft")
1466 clearPrevious(mb.Junk, "Junk")
1467 clearPrevious(mb.Sent, "Sent")
1468 clearPrevious(mb.Trash, "Trash")
1470 xmb.SpecialUse = mb.SpecialUse
1471 err := tx.Update(&xmb)
1472 xcheckf(ctx, err, "updating special-use flags for mailbox")
1473 changes = append(changes, xmb.ChangeSpecialUse())
1476 store.BroadcastChanges(acc, changes)
1480// ThreadCollapse saves the ThreadCollapse field for the messages and its
1481// children. The messageIDs are typically thread roots. But not all roots
1482// (without parent) of a thread need to have the same collapsed state.
1483func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1484 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1485 acc := reqInfo.Account
1487 if len(messageIDs) == 0 {
1488 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1491 acc.WithWLock(func() {
1492 changes := make([]store.Change, 0, len(messageIDs))
1493 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1494 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1495 // (child) messages. Further refined in FilterFn.
1496 threadIDs := map[int64]struct{}{}
1497 msgIDs := map[int64]struct{}{}
1498 for _, id := range messageIDs {
1499 m := store.Message{ID: id}
1501 if err == bstore.ErrAbsent {
1502 xcheckuserf(ctx, err, "get message")
1504 xcheckf(ctx, err, "get message")
1505 threadIDs[m.ThreadID] = struct{}{}
1506 msgIDs[id] = struct{}{}
1509 var updated []store.Message
1510 q := bstore.QueryTx[store.Message](tx)
1511 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1512 q.FilterNotEqual("ThreadCollapsed", collapse)
1513 q.FilterFn(func(tm store.Message) bool {
1514 for _, id := range tm.ThreadParentIDs {
1515 if _, ok := msgIDs[id]; ok {
1519 _, ok := msgIDs[tm.ID]
1523 q.SortAsc("ID") // Consistent order for testing.
1524 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1525 xcheckf(ctx, err, "updating collapse in database")
1527 for _, m := range updated {
1528 changes = append(changes, m.ChangeThread())
1531 store.BroadcastChanges(acc, changes)
1535// ThreadMute saves the ThreadMute field for the messages and their children.
1536// If messages are muted, they are also marked collapsed.
1537func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1538 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1539 acc := reqInfo.Account
1541 if len(messageIDs) == 0 {
1542 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1545 acc.WithWLock(func() {
1546 changes := make([]store.Change, 0, len(messageIDs))
1547 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1548 threadIDs := map[int64]struct{}{}
1549 msgIDs := map[int64]struct{}{}
1550 for _, id := range messageIDs {
1551 m := store.Message{ID: id}
1553 if err == bstore.ErrAbsent {
1554 xcheckuserf(ctx, err, "get message")
1556 xcheckf(ctx, err, "get message")
1557 threadIDs[m.ThreadID] = struct{}{}
1558 msgIDs[id] = struct{}{}
1561 var updated []store.Message
1563 q := bstore.QueryTx[store.Message](tx)
1564 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1565 q.FilterFn(func(tm store.Message) bool {
1566 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1569 for _, id := range tm.ThreadParentIDs {
1570 if _, ok := msgIDs[id]; ok {
1574 _, ok := msgIDs[tm.ID]
1578 fields := map[string]any{"ThreadMuted": mute}
1580 fields["ThreadCollapsed"] = true
1582 _, err := q.UpdateFields(fields)
1583 xcheckf(ctx, err, "updating mute in database")
1585 for _, m := range updated {
1586 changes = append(changes, m.ChangeThread())
1589 store.BroadcastChanges(acc, changes)
1593// SecurityResult indicates whether a security feature is supported.
1594type SecurityResult string
1597 SecurityResultError SecurityResult = "error"
1598 SecurityResultNo SecurityResult = "no"
1599 SecurityResultYes SecurityResult = "yes"
1600 // Unknown whether supported. Finding out may only be (reasonably) possible when
1601 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1603 SecurityResultUnknown SecurityResult = "unknown"
1606// RecipientSecurity is a quick analysis of the security properties of delivery to
1607// the recipient (domain).
1608type RecipientSecurity struct {
1609 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1610 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1612 STARTTLS SecurityResult
1614 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1616 MTASTS SecurityResult
1618 // Whether MX lookup response was DNSSEC-signed.
1619 DNSSEC SecurityResult
1621 // Whether first delivery destination has DANE records.
1624 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1625 // Will be "unknown" if no delivery to the domain has been attempted yet.
1626 RequireTLS SecurityResult
1629// RecipientSecurity looks up security properties of the address in the
1630// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1631func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1632 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1635 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1636 return recipientSecurity(ctx, log, resolver, messageAddressee)
1639// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1640func logPanic(ctx context.Context) {
1645 log := pkglog.WithContext(ctx)
1646 log.Error("recover from panic", slog.Any("panic", x))
1648 metrics.PanicInc(metrics.Webmail)
1651// separate function for testing with mocked resolver.
1652func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1653 rs := RecipientSecurity{
1654 SecurityResultUnknown,
1655 SecurityResultUnknown,
1656 SecurityResultUnknown,
1657 SecurityResultUnknown,
1658 SecurityResultUnknown,
1661 msgAddr, err := mail.ParseAddress(messageAddressee)
1663 return rs, fmt.Errorf("parsing message addressee: %v", err)
1666 addr, err := smtp.ParseAddress(msgAddr.Address)
1668 return rs, fmt.Errorf("parsing address: %v", err)
1671 var wg sync.WaitGroup
1679 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1680 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1681 rs.MTASTS = SecurityResultYes
1682 } else if err == nil {
1683 rs.MTASTS = SecurityResultNo
1685 rs.MTASTS = SecurityResultError
1695 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1697 rs.DNSSEC = SecurityResultError
1700 if origNextHopAuthentic && expandedNextHopAuthentic {
1701 rs.DNSSEC = SecurityResultYes
1703 rs.DNSSEC = SecurityResultNo
1706 if !origNextHopAuthentic {
1707 rs.DANE = SecurityResultNo
1711 // We're only looking at the first host to deliver to (typically first mx destination).
1712 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1713 return // Should not happen.
1717 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1718 // error result instead of no-DANE result.
1719 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1721 rs.DANE = SecurityResultError
1725 rs.DANE = SecurityResultNo
1729 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1731 rs.DANE = SecurityResultError
1733 } else if daneRequired {
1734 rs.DANE = SecurityResultYes
1736 rs.DANE = SecurityResultNo
1740 // STARTTLS and RequireTLS
1741 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1742 acc := reqInfo.Account
1744 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1745 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1746 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1748 if err == bstore.ErrAbsent {
1750 } else if err != nil {
1751 rs.STARTTLS = SecurityResultError
1752 rs.RequireTLS = SecurityResultError
1753 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1757 rs.STARTTLS = SecurityResultYes
1759 rs.STARTTLS = SecurityResultNo
1762 rs.RequireTLS = SecurityResultYes
1764 rs.RequireTLS = SecurityResultNo
1768 xcheckf(ctx, err, "lookup recipient domain")
1775// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1776func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1777 s, err := wordDecoder.DecodeHeader(text)
1778 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1782// SettingsSave saves settings, e.g. for composing.
1783func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1784 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1785 acc := reqInfo.Account
1788 err := acc.DB.Update(ctx, &settings)
1789 xcheckf(ctx, err, "save settings")
1792func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1793 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1794 acc := reqInfo.Account
1797 xdbread(ctx, acc, func(tx *bstore.Tx) {
1798 m := xmessageID(ctx, tx, msgID)
1799 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1800 mbDst := xmailboxID(ctx, tx, mbDstID)
1802 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1805 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1807 conf, _ := acc.Conf()
1808 dest := conf.Destinations[rcptTo] // May not be present.
1809 defaultMailbox := "Inbox"
1810 if dest.Mailbox != "" {
1811 defaultMailbox = dest.Mailbox
1814 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1815 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1819 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1820 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1821 xcheckf(ctx, err, "looking up previous response for source mailbox")
1825 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1826 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1831 // Parse message for List-Id header.
1832 state := msgState{acc: acc}
1834 pm, err := parsedMessage(log, m, &state, true, false)
1835 xcheckf(ctx, err, "parsing message")
1837 // The suggested ruleset. Once all is checked, we'll return it.
1838 var nrs *config.Ruleset
1840 // If List-Id header is present, we'll treat it as a (mailing) list message.
1841 if l, ok := pm.Headers["List-Id"]; ok {
1843 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1846 var listIDDom dns.Domain
1847 listID, listIDDom = parseListID(l[0])
1849 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1853 // Check if we have a previous "No" answer for this list-id.
1854 no := store.RulesetNoListID{
1855 RcptToAddress: rcptTo,
1857 ToInbox: mbDst.Name == "Inbox",
1859 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1860 xcheckf(ctx, err, "looking up previous response for list-id")
1865 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1866 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1867 // are subscribed to, and take advantage of any reduced junk filtering.
1868 listIDDomStr := listIDDom.Name()
1870 doms := m.DKIMDomains
1871 if m.MailFromValidated {
1872 doms = append(doms, m.MailFromDomain)
1874 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1875 // of SPF verification of one host.
1876 sort.Slice(doms, func(i, j int) bool {
1877 return len(doms[i]) < len(doms[j])
1879 var listAllowDom string
1880 for _, dom := range doms {
1881 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1886 if listAllowDom == "" {
1890 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1891 nrs = &config.Ruleset{
1892 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1893 ListAllowDomain: listAllowDom,
1894 Mailbox: mbDst.Name,
1897 // Otherwise, try to make a rule based on message "From" address.
1898 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1901 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1903 no := store.RulesetNoMsgFrom{
1904 RcptToAddress: rcptTo,
1905 MsgFromAddress: msgFrom,
1906 ToInbox: mbDst.Name == "Inbox",
1908 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1909 xcheckf(ctx, err, "looking up previous response for message from address")
1914 nrs = &config.Ruleset{
1915 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1916 Mailbox: mbDst.Name,
1920 // Only suggest adding/removing rule if it isn't/is present.
1922 for _, rs := range dest.Rulesets {
1923 xrs := config.Ruleset{
1924 MsgFromRegexp: rs.MsgFromRegexp,
1925 HeadersRegexp: rs.HeadersRegexp,
1926 ListAllowDomain: rs.ListAllowDomain,
1927 Mailbox: nrs.Mailbox,
1929 if xrs.Equal(*nrs) {
1934 isRemove = mbDst.Name == defaultMailbox
1936 nrs.Mailbox = mbSrc.Name
1938 if isRemove && !have || !isRemove && have {
1942 // We'll be returning a suggested ruleset.
1943 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1949// Parse the list-id value (the value between <>) from a list-id header.
1950// Returns an empty string if it couldn't be parsed.
1951func parseListID(s string) (listID string, dom dns.Domain) {
1953 s = strings.TrimRight(s, " \t")
1954 if !strings.HasSuffix(s, ">") {
1955 return "", dns.Domain{}
1958 t := strings.Split(s, "<")
1960 return "", dns.Domain{}
1963 dom, err := dns.ParseDomain(s)
1970func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1971 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1973 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1974 dest, ok := acc.Destinations[rcptTo]
1976 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1977 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")
1980 nd := map[string]config.Destination{}
1981 for addr, d := range acc.Destinations {
1984 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
1986 acc.Destinations = nd
1988 xcheckf(ctx, err, "saving account with new ruleset")
1991func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1992 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1994 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1995 dest, ok := acc.Destinations[rcptTo]
1997 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2000 nd := map[string]config.Destination{}
2001 for addr, d := range acc.Destinations {
2004 var l []config.Ruleset
2006 for _, rs := range dest.Rulesets {
2007 if rs.Equal(ruleset) {
2014 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2018 acc.Destinations = nd
2020 xcheckf(ctx, err, "saving account with new ruleset")
2023func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2024 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2025 acc := reqInfo.Account
2029 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2031 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2033 xcheckf(ctx, err, "storing user response")
2036func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2037 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2038 acc := reqInfo.Account
2040 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2041 xcheckf(ctx, err, "storing user response")
2044func slicesAny[T any](l []T) []any {
2045 r := make([]any, len(l))
2046 for i, v := range l {
2052// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2053func (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) {