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/admin"
37 "github.com/mjl-/mox/config"
38 "github.com/mjl-/mox/dkim"
39 "github.com/mjl-/mox/dns"
40 "github.com/mjl-/mox/message"
41 "github.com/mjl-/mox/metrics"
42 "github.com/mjl-/mox/mlog"
43 "github.com/mjl-/mox/mox-"
44 "github.com/mjl-/mox/moxio"
45 "github.com/mjl-/mox/moxvar"
46 "github.com/mjl-/mox/mtasts"
47 "github.com/mjl-/mox/mtastsdb"
48 "github.com/mjl-/mox/queue"
49 "github.com/mjl-/mox/smtp"
50 "github.com/mjl-/mox/smtpclient"
51 "github.com/mjl-/mox/store"
52 "github.com/mjl-/mox/webauth"
53 "github.com/mjl-/mox/webops"
57var webmailapiJSON []byte
60 maxMessageSize int64 // From listener.
61 cookiePath string // From listener.
62 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
65func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
66 err := json.Unmarshal(buf, &doc)
68 pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
73var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
75var sherpaHandlerOpts *sherpa.HandlerOpts
77func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
78 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
82 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
84 pkglog.Fatalx("creating sherpa prometheus collector", err)
87 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
89 _, err = makeSherpaHandler(0, "", false)
91 pkglog.Fatalx("sherpa handler", err)
95// LoginPrep returns a login token, and also sets it as cookie. Both must be
96// present in the call to Login.
97func (w Webmail) LoginPrep(ctx context.Context) string {
98 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
102 _, err := cryptorand.Read(data[:])
103 xcheckf(ctx, err, "generate token")
104 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
106 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
111// Login returns a session token for the credentials, or fails with error code
112// "user:badLogin". Call LoginPrep to get a loginToken.
113func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
114 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
117 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
118 if _, ok := err.(*sherpa.Error); ok {
121 xcheckf(ctx, err, "login")
125// Logout invalidates the session token.
126func (w Webmail) Logout(ctx context.Context) {
127 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
130 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.Account.Name, reqInfo.SessionToken)
131 xcheckf(ctx, err, "logout")
134// Token returns a single-use token to use for an SSE connection. A token can only
135// be used for a single SSE connection. Tokens are stored in memory for a maximum
136// of 1 minute, with at most 10 unused tokens (the most recently created) per
138func (Webmail) Token(ctx context.Context) string {
139 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
140 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
143// Requests sends a new request for an open SSE connection. Any currently active
144// request for the connection will be canceled, but this is done asynchrously, so
145// the SSE connection may still send results for the previous request. Callers
146// should take care to ignore such results. If req.Cancel is set, no new request is
148func (Webmail) Request(ctx context.Context, req Request) {
149 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
151 if !req.Cancel && req.Page.Count <= 0 {
152 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
155 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
157 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
162// ParsedMessage returns enough to render the textual body of a message. It is
163// assumed the client already has other fields through MessageItem.
164func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
165 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
167 acc := reqInfo.Account
169 xdbread(ctx, acc, func(tx *bstore.Tx) {
170 m := xmessageID(ctx, tx, msgID)
172 state := msgState{acc: acc}
175 pm, err = parsedMessage(log, m, &state, true, false, false)
176 xcheckf(ctx, err, "parsing message")
178 if len(pm.envelope.From) == 1 {
179 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
180 xcheckf(ctx, err, "looking up view mode for from address")
186// fromAddrViewMode returns the view mode for a from address.
187func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
188 settingsViewMode := func() (store.ViewMode, error) {
189 settings := store.Settings{ID: 1}
190 if err := tx.Get(&settings); err != nil {
191 return store.ModeText, err
193 if settings.ShowHTML {
194 return store.ModeHTML, nil
196 return store.ModeText, nil
199 lp, err := smtp.ParseLocalpart(from.User)
201 return settingsViewMode()
203 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
204 fas := store.FromAddressSettings{FromAddress: fromAddr}
206 if err == bstore.ErrAbsent {
207 return settingsViewMode()
208 } else if err != nil {
209 return store.ModeText, err
211 return fas.ViewMode, nil
214// FromAddressSettingsSave saves per-"From"-address settings.
215func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
216 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
217 acc := reqInfo.Account
219 if fas.FromAddress == "" {
220 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
223 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
224 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
225 err := tx.Update(&fas)
226 xcheckf(ctx, err, "updating settings for from address")
228 err := tx.Insert(&fas)
229 xcheckf(ctx, err, "inserting settings for from address")
234// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
235// of the message in storage. Used when opening a previously saved draft message
237// If no message is find, zero is returned, not an error.
238func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
239 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
240 acc := reqInfo.Account
242 messageID, _, _ = message.MessageIDCanonical(messageID)
244 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
247 xdbread(ctx, acc, func(tx *bstore.Tx) {
248 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
249 if err == bstore.ErrAbsent {
252 xcheckf(ctx, err, "looking up message by message-id")
258// ComposeMessage is a message to be composed, for saving draft messages.
259type ComposeMessage struct {
264 ReplyTo string // If non-empty, Reply-To header to add to message.
267 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
268 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
271// MessageCompose composes a message and saves it to the mailbox. Used for
272// saving draft messages.
273func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
274 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
275 acc := reqInfo.Account
278 log.Debug("message compose")
280 // Prevent any accidental control characters, or attempts at getting bare \r or \n
282 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
283 for _, s := range l {
284 for _, c := range s {
286 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
292 fromAddr, err := parseAddress(m.From)
293 xcheckuserf(ctx, err, "parsing From address")
295 var replyTo *message.NameAddress
297 addr, err := parseAddress(m.ReplyTo)
298 xcheckuserf(ctx, err, "parsing Reply-To address")
302 var recipients []smtp.Address
304 var toAddrs []message.NameAddress
305 for _, s := range m.To {
306 addr, err := parseAddress(s)
307 xcheckuserf(ctx, err, "parsing To address")
308 toAddrs = append(toAddrs, addr)
309 recipients = append(recipients, addr.Address)
312 var ccAddrs []message.NameAddress
313 for _, s := range m.Cc {
314 addr, err := parseAddress(s)
315 xcheckuserf(ctx, err, "parsing Cc address")
316 ccAddrs = append(ccAddrs, addr)
317 recipients = append(recipients, addr.Address)
320 var bccAddrs []message.NameAddress
321 for _, s := range m.Bcc {
322 addr, err := parseAddress(s)
323 xcheckuserf(ctx, err, "parsing Bcc address")
324 bccAddrs = append(bccAddrs, addr)
325 recipients = append(recipients, addr.Address)
328 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
330 for _, a := range recipients {
331 if a.Localpart.IsInternational() {
336 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
337 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
340 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
344 // Create file to compose message into.
345 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
346 xcheckf(ctx, err, "creating temporary file for compose message")
347 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
349 // If writing to the message file fails, we abort immediately.
350 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
356 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
357 xcheckuserf(ctx, err, "making message")
358 } else if ok && errors.Is(err, message.ErrCompose) {
359 xcheckf(ctx, err, "making message")
364 // Outer message headers.
365 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
367 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
369 xc.HeaderAddrs("To", toAddrs)
370 xc.HeaderAddrs("Cc", ccAddrs)
371 xc.HeaderAddrs("Bcc", bccAddrs)
373 xc.Subject(m.Subject)
376 // Add In-Reply-To and References headers.
377 if m.ResponseMessageID > 0 {
378 xdbread(ctx, acc, func(tx *bstore.Tx) {
379 rm := xmessageID(ctx, tx, m.ResponseMessageID)
380 msgr := acc.MessageReader(rm)
383 log.Check(err, "closing message reader")
385 rp, err := rm.LoadPart(msgr)
386 xcheckf(ctx, err, "load parsed message")
387 h, err := rp.Header()
388 xcheckf(ctx, err, "parsing header")
390 if rp.Envelope == nil {
394 if rp.Envelope.MessageID != "" {
395 xc.Header("In-Reply-To", rp.Envelope.MessageID)
397 refs := h.Values("References")
398 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
399 refs = []string{rp.Envelope.InReplyTo}
401 if rp.Envelope.MessageID != "" {
402 refs = append(refs, rp.Envelope.MessageID)
405 xc.Header("References", strings.Join(refs, "\r\n\t"))
409 xc.Header("MIME-Version", "1.0")
410 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
411 xc.Header("Content-Type", ct)
412 xc.Header("Content-Transfer-Encoding", cte)
414 xc.Write([]byte(textBody))
419 // Remove previous draft message, append message to destination mailbox.
420 acc.WithRLock(func() {
421 var changes []store.Change
423 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
424 var modseq store.ModSeq // Only set if needed.
426 if m.DraftMessageID > 0 {
427 var nchanges []store.Change
428 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
429 changes = append(changes, nchanges...)
430 // On-disk file is removed after lock.
433 // Find mailbox to write to.
434 mb := store.Mailbox{ID: mailboxID}
436 if err == bstore.ErrAbsent {
437 xcheckuserf(ctx, err, "looking up mailbox")
439 xcheckf(ctx, err, "looking up mailbox")
442 modseq, err = acc.NextModSeq(tx)
443 xcheckf(ctx, err, "next modseq")
450 MailboxOrigID: mb.ID,
451 Flags: store.Flags{Notjunk: true},
455 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
456 xcheckf(ctx, err, "checking quota")
458 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
461 // Update mailbox before delivery, which changes uidnext.
462 mb.Add(nm.MailboxCounts())
464 xcheckf(ctx, err, "updating sent mailbox for counts")
466 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
467 xcheckf(ctx, err, "storing message in mailbox")
469 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
472 store.BroadcastChanges(acc, changes)
475 // Remove on-disk file for removed draft message.
476 if m.DraftMessageID > 0 {
477 p := acc.MessagePath(m.DraftMessageID)
479 log.Check(err, "removing draft message file")
485// Attachment is a MIME part is an existing message that is not intended as
486// viewable text or HTML part.
487type Attachment struct {
488 Path []int // Indices into top-level message.Part.Parts.
490 // File name based on "name" attribute of "Content-Type", or the "filename"
491 // attribute of "Content-Disposition".
497// SubmitMessage is an email message to be sent to one or more recipients.
498// Addresses are formatted as just email address, or with a name like "name
500type SubmitMessage struct {
505 ReplyTo string // If non-empty, Reply-To header to add to message.
509 ForwardAttachments ForwardAttachments
511 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
512 UserAgent string // User-Agent header added if not empty.
513 RequireTLS *bool // For "Require TLS" extension during delivery.
514 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
515 ArchiveThread bool // If set, thread is archived after sending message.
516 ArchiveReferenceMailboxID int64 // If ArchiveThread is set, thread messages from this mailbox ID are moved to the archive mailbox ID. E.g. of Inbox.
517 DraftMessageID int64 // If set, draft message that will be removed after sending.
520// ForwardAttachments references attachments by a list of message.Part paths.
521type ForwardAttachments struct {
522 MessageID int64 // Only relevant if MessageID is not 0.
523 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
526// File is a new attachment (not from an existing message that is being
527// forwarded) to send with a SubmitMessage.
530 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
533// parseAddress expects either a plain email address like "user@domain", or a
534// single address as used in a message header, like "name <user@domain>".
535func parseAddress(msghdr string) (message.NameAddress, error) {
537 parser := mail.AddressParser{WordDecoder: &wordDecoder}
538 a, err := parser.Parse(msghdr)
540 return message.NameAddress{}, err
543 path, err := smtp.ParseNetMailAddress(a.Address)
545 return message.NameAddress{}, err
547 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
550func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
552 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
554 mb := store.Mailbox{ID: mailboxID}
556 if err == bstore.ErrAbsent {
557 xcheckuserf(ctx, err, "getting mailbox")
559 xcheckf(ctx, err, "getting mailbox")
563// xmessageID returns a non-expunged message or panics with a sherpa error.
564func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
566 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
568 m := store.Message{ID: messageID}
570 if err == bstore.ErrAbsent {
571 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
572 } else if err == nil && m.Expunged {
573 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
575 xcheckf(ctx, err, "getting message")
579func xrandomID(ctx context.Context, n int) string {
580 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
583func xrandom(ctx context.Context, n int) []byte {
584 buf := make([]byte, n)
585 x, err := cryptorand.Read(buf)
586 xcheckf(ctx, err, "read random")
588 xcheckf(ctx, errors.New("short random read"), "read random")
593// MessageSubmit sends a message by submitting it the outgoing email queue. The
594// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
595// Bcc message header.
597// If a Sent mailbox is configured, messages are added to it after submitting
598// to the delivery queue. If Bcc addresses were present, a header is prepended
599// to the message stored in the Sent mailbox.
600func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
601 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
602 acc := reqInfo.Account
605 log.Debug("message submit")
607 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
609 // 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.
611 // Prevent any accidental control characters, or attempts at getting bare \r or \n
613 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
614 for _, s := range l {
615 for _, c := range s {
617 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
623 fromAddr, err := parseAddress(m.From)
624 xcheckuserf(ctx, err, "parsing From address")
626 var replyTo *message.NameAddress
628 a, err := parseAddress(m.ReplyTo)
629 xcheckuserf(ctx, err, "parsing Reply-To address")
633 var recipients []smtp.Address
635 var toAddrs []message.NameAddress
636 for _, s := range m.To {
637 addr, err := parseAddress(s)
638 xcheckuserf(ctx, err, "parsing To address")
639 toAddrs = append(toAddrs, addr)
640 recipients = append(recipients, addr.Address)
643 var ccAddrs []message.NameAddress
644 for _, s := range m.Cc {
645 addr, err := parseAddress(s)
646 xcheckuserf(ctx, err, "parsing Cc address")
647 ccAddrs = append(ccAddrs, addr)
648 recipients = append(recipients, addr.Address)
651 var bccAddrs []message.NameAddress
652 for _, s := range m.Bcc {
653 addr, err := parseAddress(s)
654 xcheckuserf(ctx, err, "parsing Bcc address")
655 bccAddrs = append(bccAddrs, addr)
656 recipients = append(recipients, addr.Address)
659 // Check if from address is allowed for account.
660 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
661 metricSubmission.WithLabelValues("badfrom").Inc()
662 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
665 if len(recipients) == 0 {
666 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
669 // Check outgoing message rate limit.
670 xdbread(ctx, acc, func(tx *bstore.Tx) {
671 rcpts := make([]smtp.Path, len(recipients))
672 for i, r := range recipients {
673 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
675 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
677 metricSubmission.WithLabelValues("messagelimiterror").Inc()
678 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
679 } else if rcptlimit >= 0 {
680 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
681 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
683 xcheckf(ctx, err, "checking send limit")
686 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
688 for _, a := range recipients {
689 if a.Localpart.IsInternational() {
694 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
695 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
698 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
702 // Create file to compose message into.
703 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
704 xcheckf(ctx, err, "creating temporary file for message")
705 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
707 // If writing to the message file fails, we abort immediately.
708 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
714 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
715 xcheckuserf(ctx, err, "making message")
716 } else if ok && errors.Is(err, message.ErrCompose) {
717 xcheckf(ctx, err, "making message")
722 // 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
724 // Each queued message gets a Received header.
725 // We don't have access to the local IP for adding.
726 // We cannot use VIA, because there is no registered method. We would like to use
727 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
728 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
729 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
730 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
731 recvHdrFor := func(rcptTo string) string {
732 recvHdr := &message.HeaderWriter{}
733 // For additional Received-header clauses, see:
734 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
735 // Note: we don't have "via" or "with", there is no registered for webmail.
736 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
737 if reqInfo.Request.TLS != nil {
738 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
740 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
741 return recvHdr.String()
744 // Outer message headers.
745 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
747 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
749 xc.HeaderAddrs("To", toAddrs)
750 xc.HeaderAddrs("Cc", ccAddrs)
751 // We prepend Bcc headers to the message when adding to the Sent mailbox.
753 xc.Subject(m.Subject)
756 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
757 xc.Header("Message-Id", messageID)
758 xc.Header("Date", time.Now().Format(message.RFC5322Z))
759 // Add In-Reply-To and References headers.
760 if m.ResponseMessageID > 0 {
761 xdbread(ctx, acc, func(tx *bstore.Tx) {
762 rm := xmessageID(ctx, tx, m.ResponseMessageID)
763 msgr := acc.MessageReader(rm)
766 log.Check(err, "closing message reader")
768 rp, err := rm.LoadPart(msgr)
769 xcheckf(ctx, err, "load parsed message")
770 h, err := rp.Header()
771 xcheckf(ctx, err, "parsing header")
773 if rp.Envelope == nil {
777 if rp.Envelope.MessageID != "" {
778 xc.Header("In-Reply-To", rp.Envelope.MessageID)
780 refs := h.Values("References")
781 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
782 refs = []string{rp.Envelope.InReplyTo}
784 if rp.Envelope.MessageID != "" {
785 refs = append(refs, rp.Envelope.MessageID)
788 xc.Header("References", strings.Join(refs, "\r\n\t"))
792 if m.UserAgent != "" {
793 xc.Header("User-Agent", m.UserAgent)
795 if m.RequireTLS != nil && !*m.RequireTLS {
796 xc.Header("TLS-Required", "No")
798 xc.Header("MIME-Version", "1.0")
800 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
801 mp := multipart.NewWriter(xc)
802 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
805 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
806 textHdr := textproto.MIMEHeader{}
807 textHdr.Set("Content-Type", ct)
808 textHdr.Set("Content-Transfer-Encoding", cte)
810 textp, err := mp.CreatePart(textHdr)
811 xcheckf(ctx, err, "adding text part to message")
812 _, err = textp.Write(textBody)
813 xcheckf(ctx, err, "writing text part")
815 xaddPart := func(ct, filename string) io.Writer {
816 ahdr := textproto.MIMEHeader{}
817 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
819 ahdr.Set("Content-Type", ct)
820 ahdr.Set("Content-Transfer-Encoding", "base64")
821 ahdr.Set("Content-Disposition", cd)
822 ap, err := mp.CreatePart(ahdr)
823 xcheckf(ctx, err, "adding attachment part to message")
827 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
828 ap := xaddPart(ct, filename)
830 for len(base64Data) > 0 {
836 line, base64Data = base64Data[:n], base64Data[n:]
837 _, err := ap.Write(line)
838 xcheckf(ctx, err, "writing attachment")
839 _, err = ap.Write([]byte("\r\n"))
840 xcheckf(ctx, err, "writing attachment")
844 xaddAttachment := func(ct, filename string, r io.Reader) {
845 ap := xaddPart(ct, filename)
846 wc := moxio.Base64Writer(ap)
847 _, err := io.Copy(wc, r)
848 xcheckf(ctx, err, "adding attachment")
850 xcheckf(ctx, err, "flushing attachment")
853 for _, a := range m.Attachments {
855 if !strings.HasPrefix(s, "data:") {
856 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
859 t := strings.SplitN(s, ",", 2)
861 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
863 if !strings.HasSuffix(t[0], "base64") {
864 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
866 ct := strings.TrimSuffix(t[0], "base64")
867 ct = strings.TrimSuffix(ct, ";")
869 ct = "application/octet-stream"
871 filename := a.Filename
873 filename = "unnamed.bin"
875 params := map[string]string{"name": filename}
876 ct = mime.FormatMediaType(ct, params)
878 // Ensure base64 is valid, then we'll write the original string.
879 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
880 xcheckuserf(ctx, err, "parsing attachment as base64")
882 xaddAttachmentBase64(ct, filename, []byte(t[1]))
885 if len(m.ForwardAttachments.Paths) > 0 {
886 acc.WithRLock(func() {
887 xdbread(ctx, acc, func(tx *bstore.Tx) {
888 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
889 msgr := acc.MessageReader(fm)
892 log.Check(err, "closing message reader")
895 fp, err := fm.LoadPart(msgr)
896 xcheckf(ctx, err, "load parsed message")
898 for _, path := range m.ForwardAttachments.Paths {
900 for _, xp := range path {
901 if xp < 0 || xp >= len(ap.Parts) {
902 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
907 _, filename, err := ap.DispositionFilename()
908 if err != nil && errors.Is(err, message.ErrParamEncoding) {
909 log.Debugx("parsing disposition/filename", err)
911 xcheckf(ctx, err, "reading disposition")
914 filename = "unnamed.bin"
916 params := map[string]string{"name": filename}
917 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
918 params["charset"] = pcharset
920 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
921 ct = mime.FormatMediaType(ct, params)
922 xaddAttachment(ct, filename, ap.Reader())
929 xcheckf(ctx, err, "writing mime multipart")
931 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
932 xc.Header("Content-Type", ct)
933 xc.Header("Content-Transfer-Encoding", cte)
935 xc.Write([]byte(textBody))
940 // Add DKIM-Signature headers.
942 fd := fromAddr.Address.Domain
943 confDom, _ := mox.Conf.Domain(fd)
944 selectors := mox.DKIMSelectors(confDom.DKIM)
945 if len(selectors) > 0 {
946 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
948 metricServerErrors.WithLabelValues("dkimsign").Inc()
950 xcheckf(ctx, err, "sign dkim")
952 msgPrefix = dkimHeaders
955 accConf, _ := acc.Conf()
956 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
957 xcheckf(ctx, err, "parsing login address")
958 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
959 fromPath := fromAddr.Address.Path()
960 var localpartBase string
962 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
964 qml := make([]queue.Msg, len(recipients))
966 for i, rcpt := range recipients {
970 fromID = xrandomID(ctx, 16)
971 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
974 // Don't use per-recipient unique message prefix when multiple recipients are
975 // present, or the queue cannot deliver it in a single smtp transaction.
977 if len(recipients) == 1 {
978 recvRcpt = rcpt.Pack(smtputf8)
980 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
981 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
983 Localpart: rcpt.Localpart,
984 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
986 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
987 if m.FutureRelease != nil {
988 ival := time.Until(*m.FutureRelease)
990 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
991 } else if ival > queue.FutureReleaseIntervalMax {
992 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
994 qm.NextAttempt = *m.FutureRelease
995 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
996 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
999 // no qm.Extra from webmail
1002 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
1004 metricSubmission.WithLabelValues("queueerror").Inc()
1006 xcheckf(ctx, err, "adding messages to the delivery queue")
1007 metricSubmission.WithLabelValues("ok").Inc()
1009 var modseq store.ModSeq // Only set if needed.
1011 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1012 // remove any draft message.
1013 acc.WithRLock(func() {
1014 var changes []store.Change
1018 if x := recover(); x != nil {
1020 metricServerErrors.WithLabelValues("submit").Inc()
1025 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1026 if m.DraftMessageID > 0 {
1027 var nchanges []store.Change
1028 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1029 changes = append(changes, nchanges...)
1030 // On-disk file is removed after lock.
1033 if m.ResponseMessageID > 0 {
1034 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1041 if !rm.Junk && !rm.Notjunk {
1044 if rm.Flags != oflags {
1045 modseq, err = acc.NextModSeq(tx)
1046 xcheckf(ctx, err, "next modseq")
1048 err := tx.Update(&rm)
1049 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1050 changes = append(changes, rm.ChangeFlags(oflags))
1052 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1053 xcheckf(ctx, err, "retraining messages after reply/forward")
1056 // Move messages from this thread still in this mailbox to the designated Archive
1058 if m.ArchiveThread {
1059 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1060 if err == bstore.ErrAbsent {
1061 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1063 xcheckf(ctx, err, "looking up designated archive mailbox")
1066 q := bstore.QueryTx[store.Message](tx)
1067 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1068 q.FilterEqual("Expunged", false)
1069 err = q.IDs(&msgIDs)
1070 xcheckf(ctx, err, "listing messages in thread to archive")
1071 if len(msgIDs) > 0 {
1072 var nchanges []store.Change
1073 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1074 changes = append(changes, nchanges...)
1079 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1080 if err == bstore.ErrAbsent {
1081 // There is no mailbox designated as Sent mailbox, so we're done.
1084 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1087 modseq, err = acc.NextModSeq(tx)
1088 xcheckf(ctx, err, "next modseq")
1091 // If there were bcc headers, prepend those to the stored message only, before the
1092 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1093 // message won't validate with DKIM anymore, which is fine.
1094 if len(bccAddrs) > 0 {
1095 var sb strings.Builder
1096 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1097 xbcc.HeaderAddrs("Bcc", bccAddrs)
1099 msgPrefix = sb.String() + msgPrefix
1102 sentm := store.Message{
1105 MailboxID: sentmb.ID,
1106 MailboxOrigID: sentmb.ID,
1107 Flags: store.Flags{Notjunk: true, Seen: true},
1108 Size: int64(len(msgPrefix)) + xc.Size,
1109 MsgPrefix: []byte(msgPrefix),
1112 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1113 xcheckf(ctx, err, "checking quota")
1115 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1118 // Update mailbox before delivery, which changes uidnext.
1119 sentmb.Add(sentm.MailboxCounts())
1120 err = tx.Update(&sentmb)
1121 xcheckf(ctx, err, "updating sent mailbox for counts")
1123 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1125 metricSubmission.WithLabelValues("storesenterror").Inc()
1128 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1130 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1133 store.BroadcastChanges(acc, changes)
1136 // Remove on-disk file for removed draft message.
1137 if m.DraftMessageID > 0 {
1138 p := acc.MessagePath(m.DraftMessageID)
1140 log.Check(err, "removing draft message file")
1144// MessageMove moves messages to another mailbox. If the message is already in
1145// the mailbox an error is returned.
1146func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1147 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1148 acc := reqInfo.Account
1151 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1154var xops = webops.XOps{
1157 Checkuserf: xcheckuserf,
1160// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1161func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1162 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1163 acc := reqInfo.Account
1166 if len(messageIDs) == 0 {
1170 xops.MessageDelete(ctx, log, acc, messageIDs)
1173// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1174// flags should be lower-case, but will be converted and verified.
1175func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1176 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1177 acc := reqInfo.Account
1180 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1183// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1184func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1185 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1186 acc := reqInfo.Account
1189 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1192// MailboxCreate creates a new mailbox.
1193func (Webmail) MailboxCreate(ctx context.Context, name string) {
1194 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1195 acc := reqInfo.Account
1198 name, _, err = store.CheckMailboxName(name, false)
1199 xcheckuserf(ctx, err, "checking mailbox name")
1201 acc.WithWLock(func() {
1202 var changes []store.Change
1203 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1206 changes, _, exists, err = acc.MailboxCreate(tx, name)
1208 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1210 xcheckf(ctx, err, "creating mailbox")
1213 store.BroadcastChanges(acc, changes)
1217// MailboxDelete deletes a mailbox and all its messages.
1218func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1219 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1220 acc := reqInfo.Account
1223 // Messages to remove after having broadcasted the removal of messages.
1224 var removeMessageIDs []int64
1226 acc.WithWLock(func() {
1227 var changes []store.Change
1229 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1230 mb := xmailboxID(ctx, tx, mailboxID)
1231 if mb.Name == "Inbox" {
1232 // Inbox is special in IMAP and cannot be removed.
1233 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1236 var hasChildren bool
1238 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1240 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1242 xcheckf(ctx, err, "deleting mailbox")
1245 store.BroadcastChanges(acc, changes)
1248 for _, mID := range removeMessageIDs {
1249 p := acc.MessagePath(mID)
1251 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1255// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1256// its child mailboxes.
1257func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1258 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1259 acc := reqInfo.Account
1262 var expunged []store.Message
1264 acc.WithWLock(func() {
1265 var changes []store.Change
1267 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1268 mb := xmailboxID(ctx, tx, mailboxID)
1270 modseq, err := acc.NextModSeq(tx)
1271 xcheckf(ctx, err, "next modseq")
1273 // Mark messages as expunged.
1274 qm := bstore.QueryTx[store.Message](tx)
1275 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1276 qm.FilterEqual("Expunged", false)
1278 qm.Gather(&expunged)
1279 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1280 xcheckf(ctx, err, "deleting messages")
1282 // Remove Recipients.
1283 anyIDs := make([]any, len(expunged))
1284 for i, m := range expunged {
1287 qmr := bstore.QueryTx[store.Recipient](tx)
1288 qmr.FilterEqual("MessageID", anyIDs...)
1289 _, err = qmr.Delete()
1290 xcheckf(ctx, err, "removing message recipients")
1292 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1294 uids := make([]store.UID, len(expunged))
1295 for i, m := range expunged {
1296 m.Expunged = false // Gather returns updated values.
1297 mb.Sub(m.MailboxCounts())
1301 expunged[i].Junk = false
1302 expunged[i].Notjunk = false
1305 err = tx.Update(&mb)
1306 xcheckf(ctx, err, "updating mailbox for counts")
1308 err = acc.AddMessageSize(log, tx, -totalSize)
1309 xcheckf(ctx, err, "updating disk usage")
1311 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1312 xcheckf(ctx, err, "retraining expunged messages")
1314 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1315 changes = []store.Change{chremove, mb.ChangeCounts()}
1318 store.BroadcastChanges(acc, changes)
1321 for _, m := range expunged {
1322 p := acc.MessagePath(m.ID)
1324 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1328// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1329// ID and its messages are unchanged.
1330func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1331 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1332 acc := reqInfo.Account
1334 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1335 // standard. We can just say no.
1337 newName, _, err = store.CheckMailboxName(newName, false)
1338 xcheckuserf(ctx, err, "checking new mailbox name")
1340 acc.WithWLock(func() {
1341 var changes []store.Change
1343 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1344 mbsrc := xmailboxID(ctx, tx, mailboxID)
1346 var isInbox, notExists, alreadyExists bool
1347 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1348 if isInbox || notExists || alreadyExists {
1349 xcheckuserf(ctx, err, "renaming mailbox")
1351 xcheckf(ctx, err, "renaming mailbox")
1354 store.BroadcastChanges(acc, changes)
1358// CompleteRecipient returns autocomplete matches for a recipient, returning the
1359// matches, most recently used first, and whether this is the full list and further
1360// requests for longer prefixes aren't necessary.
1361func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1362 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1363 acc := reqInfo.Account
1365 search = strings.ToLower(search)
1367 var matches []string
1369 acc.WithRLock(func() {
1370 xdbread(ctx, acc, func(tx *bstore.Tx) {
1375 seen := map[key]bool{}
1377 q := bstore.QueryTx[store.Recipient](tx)
1379 err := q.ForEach(func(r store.Recipient) error {
1380 k := key{r.Localpart, r.Domain}
1384 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1385 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1386 if !strings.Contains(strings.ToLower(address), search) {
1389 if len(matches) >= 20 {
1391 return bstore.StopForEach
1394 // Look in the message that was sent for a name along with the address.
1395 m := store.Message{ID: r.MessageID}
1397 xcheckf(ctx, err, "get sent message")
1398 if !m.Expunged && m.ParsedBuf != nil {
1399 var part message.Part
1400 err := json.Unmarshal(m.ParsedBuf, &part)
1401 xcheckf(ctx, err, "parsing part")
1403 dom, err := dns.ParseDomain(r.Domain)
1404 xcheckf(ctx, err, "parsing domain of recipient")
1408 checkAddrs := func(l []message.Address) {
1412 for _, a := range l {
1413 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1415 address = addressString(a, false)
1420 if part.Envelope != nil {
1421 env := part.Envelope
1428 matches = append(matches, address)
1432 xcheckf(ctx, err, "listing recipients")
1438// addressString returns an address into a string as it could be used in a message header.
1439func addressString(a message.Address, smtputf8 bool) string {
1441 dom, err := dns.ParseDomain(a.Host)
1443 if smtputf8 && dom.Unicode != "" {
1449 s := "<" + a.User + "@" + host + ">"
1451 // todo: properly encoded/escaped name
1452 s = a.Name + " " + s
1457// MailboxSetSpecialUse sets the special use flags of a mailbox.
1458func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1459 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1460 acc := reqInfo.Account
1462 acc.WithWLock(func() {
1463 var changes []store.Change
1465 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1466 xmb := xmailboxID(ctx, tx, mb.ID)
1468 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1469 // we set, we clear it for the mailbox(es) that had it, if any.
1470 clearPrevious := func(clear bool, specialUse string) {
1474 var ombl []store.Mailbox
1475 q := bstore.QueryTx[store.Mailbox](tx)
1476 q.FilterNotEqual("ID", mb.ID)
1477 q.FilterEqual(specialUse, true)
1479 _, err := q.UpdateField(specialUse, false)
1480 xcheckf(ctx, err, "updating previous special-use mailboxes")
1482 for _, omb := range ombl {
1483 changes = append(changes, omb.ChangeSpecialUse())
1486 clearPrevious(mb.Archive, "Archive")
1487 clearPrevious(mb.Draft, "Draft")
1488 clearPrevious(mb.Junk, "Junk")
1489 clearPrevious(mb.Sent, "Sent")
1490 clearPrevious(mb.Trash, "Trash")
1492 xmb.SpecialUse = mb.SpecialUse
1493 err := tx.Update(&xmb)
1494 xcheckf(ctx, err, "updating special-use flags for mailbox")
1495 changes = append(changes, xmb.ChangeSpecialUse())
1498 store.BroadcastChanges(acc, changes)
1502// ThreadCollapse saves the ThreadCollapse field for the messages and its
1503// children. The messageIDs are typically thread roots. But not all roots
1504// (without parent) of a thread need to have the same collapsed state.
1505func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1506 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1507 acc := reqInfo.Account
1509 if len(messageIDs) == 0 {
1510 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1513 acc.WithWLock(func() {
1514 changes := make([]store.Change, 0, len(messageIDs))
1515 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1516 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1517 // (child) messages. Further refined in FilterFn.
1518 threadIDs := map[int64]struct{}{}
1519 msgIDs := map[int64]struct{}{}
1520 for _, id := range messageIDs {
1521 m := store.Message{ID: id}
1523 if err == bstore.ErrAbsent {
1524 xcheckuserf(ctx, err, "get message")
1526 xcheckf(ctx, err, "get message")
1527 threadIDs[m.ThreadID] = struct{}{}
1528 msgIDs[id] = struct{}{}
1531 var updated []store.Message
1532 q := bstore.QueryTx[store.Message](tx)
1533 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1534 q.FilterNotEqual("ThreadCollapsed", collapse)
1535 q.FilterFn(func(tm store.Message) bool {
1536 for _, id := range tm.ThreadParentIDs {
1537 if _, ok := msgIDs[id]; ok {
1541 _, ok := msgIDs[tm.ID]
1545 q.SortAsc("ID") // Consistent order for testing.
1546 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1547 xcheckf(ctx, err, "updating collapse in database")
1549 for _, m := range updated {
1550 changes = append(changes, m.ChangeThread())
1553 store.BroadcastChanges(acc, changes)
1557// ThreadMute saves the ThreadMute field for the messages and their children.
1558// If messages are muted, they are also marked collapsed.
1559func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1560 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1561 acc := reqInfo.Account
1563 if len(messageIDs) == 0 {
1564 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1567 acc.WithWLock(func() {
1568 changes := make([]store.Change, 0, len(messageIDs))
1569 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1570 threadIDs := map[int64]struct{}{}
1571 msgIDs := map[int64]struct{}{}
1572 for _, id := range messageIDs {
1573 m := store.Message{ID: id}
1575 if err == bstore.ErrAbsent {
1576 xcheckuserf(ctx, err, "get message")
1578 xcheckf(ctx, err, "get message")
1579 threadIDs[m.ThreadID] = struct{}{}
1580 msgIDs[id] = struct{}{}
1583 var updated []store.Message
1585 q := bstore.QueryTx[store.Message](tx)
1586 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1587 q.FilterFn(func(tm store.Message) bool {
1588 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1591 for _, id := range tm.ThreadParentIDs {
1592 if _, ok := msgIDs[id]; ok {
1596 _, ok := msgIDs[tm.ID]
1600 fields := map[string]any{"ThreadMuted": mute}
1602 fields["ThreadCollapsed"] = true
1604 _, err := q.UpdateFields(fields)
1605 xcheckf(ctx, err, "updating mute in database")
1607 for _, m := range updated {
1608 changes = append(changes, m.ChangeThread())
1611 store.BroadcastChanges(acc, changes)
1615// SecurityResult indicates whether a security feature is supported.
1616type SecurityResult string
1619 SecurityResultError SecurityResult = "error"
1620 SecurityResultNo SecurityResult = "no"
1621 SecurityResultYes SecurityResult = "yes"
1622 // Unknown whether supported. Finding out may only be (reasonably) possible when
1623 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1625 SecurityResultUnknown SecurityResult = "unknown"
1628// RecipientSecurity is a quick analysis of the security properties of delivery to
1629// the recipient (domain).
1630type RecipientSecurity struct {
1631 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1632 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1634 STARTTLS SecurityResult
1636 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1638 MTASTS SecurityResult
1640 // Whether MX lookup response was DNSSEC-signed.
1641 DNSSEC SecurityResult
1643 // Whether first delivery destination has DANE records.
1646 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1647 // Will be "unknown" if no delivery to the domain has been attempted yet.
1648 RequireTLS SecurityResult
1651// RecipientSecurity looks up security properties of the address in the
1652// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1653func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1654 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1657 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1658 return recipientSecurity(ctx, log, resolver, messageAddressee)
1661// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1662func logPanic(ctx context.Context) {
1667 log := pkglog.WithContext(ctx)
1668 log.Error("recover from panic", slog.Any("panic", x))
1670 metrics.PanicInc(metrics.Webmail)
1673// separate function for testing with mocked resolver.
1674func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1675 rs := RecipientSecurity{
1676 SecurityResultUnknown,
1677 SecurityResultUnknown,
1678 SecurityResultUnknown,
1679 SecurityResultUnknown,
1680 SecurityResultUnknown,
1683 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1684 msgAddr, err := parser.Parse(messageAddressee)
1686 return rs, fmt.Errorf("parsing addressee: %v", err)
1688 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1690 return rs, fmt.Errorf("parsing address: %v", err)
1693 var wg sync.WaitGroup
1701 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1702 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1703 rs.MTASTS = SecurityResultYes
1704 } else if err == nil {
1705 rs.MTASTS = SecurityResultNo
1707 rs.MTASTS = SecurityResultError
1717 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1719 rs.DNSSEC = SecurityResultError
1722 if origNextHopAuthentic && expandedNextHopAuthentic {
1723 rs.DNSSEC = SecurityResultYes
1725 rs.DNSSEC = SecurityResultNo
1728 if !origNextHopAuthentic {
1729 rs.DANE = SecurityResultNo
1733 // We're only looking at the first host to deliver to (typically first mx destination).
1734 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1735 return // Should not happen.
1739 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1740 // error result instead of no-DANE result.
1741 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1743 rs.DANE = SecurityResultError
1747 rs.DANE = SecurityResultNo
1751 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1753 rs.DANE = SecurityResultError
1755 } else if daneRequired {
1756 rs.DANE = SecurityResultYes
1758 rs.DANE = SecurityResultNo
1762 // STARTTLS and RequireTLS
1763 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1764 acc := reqInfo.Account
1766 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1767 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1768 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1770 if err == bstore.ErrAbsent {
1772 } else if err != nil {
1773 rs.STARTTLS = SecurityResultError
1774 rs.RequireTLS = SecurityResultError
1775 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1779 rs.STARTTLS = SecurityResultYes
1781 rs.STARTTLS = SecurityResultNo
1784 rs.RequireTLS = SecurityResultYes
1786 rs.RequireTLS = SecurityResultNo
1790 xcheckf(ctx, err, "lookup recipient domain")
1797// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1798func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1799 s, err := wordDecoder.DecodeHeader(text)
1800 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1804// SettingsSave saves settings, e.g. for composing.
1805func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1806 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1807 acc := reqInfo.Account
1810 err := acc.DB.Update(ctx, &settings)
1811 xcheckf(ctx, err, "save settings")
1814func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1815 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1816 acc := reqInfo.Account
1819 xdbread(ctx, acc, func(tx *bstore.Tx) {
1820 m := xmessageID(ctx, tx, msgID)
1821 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1822 mbDst := xmailboxID(ctx, tx, mbDstID)
1824 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1827 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1829 conf, _ := acc.Conf()
1830 dest := conf.Destinations[rcptTo] // May not be present.
1831 defaultMailbox := "Inbox"
1832 if dest.Mailbox != "" {
1833 defaultMailbox = dest.Mailbox
1836 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1837 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1841 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1842 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1843 xcheckf(ctx, err, "looking up previous response for source mailbox")
1847 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1848 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1853 // Parse message for List-Id header.
1854 state := msgState{acc: acc}
1856 pm, err := parsedMessage(log, m, &state, true, false, false)
1857 xcheckf(ctx, err, "parsing message")
1859 // The suggested ruleset. Once all is checked, we'll return it.
1860 var nrs *config.Ruleset
1862 // If List-Id header is present, we'll treat it as a (mailing) list message.
1863 if l, ok := pm.Headers["List-Id"]; ok {
1865 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1868 var listIDDom dns.Domain
1869 listID, listIDDom = parseListID(l[0])
1871 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1875 // Check if we have a previous "No" answer for this list-id.
1876 no := store.RulesetNoListID{
1877 RcptToAddress: rcptTo,
1879 ToInbox: mbDst.Name == "Inbox",
1881 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1882 xcheckf(ctx, err, "looking up previous response for list-id")
1887 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1888 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1889 // are subscribed to, and take advantage of any reduced junk filtering.
1890 listIDDomStr := listIDDom.Name()
1892 doms := m.DKIMDomains
1893 if m.MailFromValidated {
1894 doms = append(doms, m.MailFromDomain)
1896 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1897 // of SPF verification of one host.
1898 sort.Slice(doms, func(i, j int) bool {
1899 return len(doms[i]) < len(doms[j])
1901 var listAllowDom string
1902 for _, dom := range doms {
1903 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1908 if listAllowDom == "" {
1912 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1913 nrs = &config.Ruleset{
1914 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1915 ListAllowDomain: listAllowDom,
1916 Mailbox: mbDst.Name,
1919 // Otherwise, try to make a rule based on message "From" address.
1920 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1923 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1925 no := store.RulesetNoMsgFrom{
1926 RcptToAddress: rcptTo,
1927 MsgFromAddress: msgFrom,
1928 ToInbox: mbDst.Name == "Inbox",
1930 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1931 xcheckf(ctx, err, "looking up previous response for message from address")
1936 nrs = &config.Ruleset{
1937 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1938 Mailbox: mbDst.Name,
1942 // Only suggest adding/removing rule if it isn't/is present.
1944 for _, rs := range dest.Rulesets {
1945 xrs := config.Ruleset{
1946 MsgFromRegexp: rs.MsgFromRegexp,
1947 HeadersRegexp: rs.HeadersRegexp,
1948 ListAllowDomain: rs.ListAllowDomain,
1949 Mailbox: nrs.Mailbox,
1951 if xrs.Equal(*nrs) {
1956 isRemove = mbDst.Name == defaultMailbox
1958 nrs.Mailbox = mbSrc.Name
1960 if isRemove && !have || !isRemove && have {
1964 // We'll be returning a suggested ruleset.
1965 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1971// Parse the list-id value (the value between <>) from a list-id header.
1972// Returns an empty string if it couldn't be parsed.
1973func parseListID(s string) (listID string, dom dns.Domain) {
1975 s = strings.TrimRight(s, " \t")
1976 if !strings.HasSuffix(s, ">") {
1977 return "", dns.Domain{}
1980 t := strings.Split(s, "<")
1982 return "", dns.Domain{}
1985 dom, err := dns.ParseDomain(s)
1992func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1993 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1995 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1996 dest, ok := acc.Destinations[rcptTo]
1998 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1999 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")
2002 nd := map[string]config.Destination{}
2003 for addr, d := range acc.Destinations {
2006 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2008 acc.Destinations = nd
2010 xcheckf(ctx, err, "saving account with new ruleset")
2013func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2014 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2016 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2017 dest, ok := acc.Destinations[rcptTo]
2019 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2022 nd := map[string]config.Destination{}
2023 for addr, d := range acc.Destinations {
2026 var l []config.Ruleset
2028 for _, rs := range dest.Rulesets {
2029 if rs.Equal(ruleset) {
2036 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2040 acc.Destinations = nd
2042 xcheckf(ctx, err, "saving account with new ruleset")
2045func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2046 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2047 acc := reqInfo.Account
2051 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2053 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2055 xcheckf(ctx, err, "storing user response")
2058func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2059 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2060 acc := reqInfo.Account
2062 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2063 xcheckf(ctx, err, "storing user response")
2066func slicesAny[T any](l []T) []any {
2067 r := make([]any, len(l))
2068 for i, v := range l {
2074// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2075func (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) {