5 cryptorand "crypto/rand"
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// Version returns the version, goos and goarch.
135func (w Webmail) Version(ctx context.Context) (version, goos, goarch string) {
136 return moxvar.Version, runtime.GOOS, runtime.GOARCH
139// Token returns a single-use token to use for an SSE connection. A token can only
140// be used for a single SSE connection. Tokens are stored in memory for a maximum
141// of 1 minute, with at most 10 unused tokens (the most recently created) per
143func (Webmail) Token(ctx context.Context) string {
144 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
145 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
148// Requests sends a new request for an open SSE connection. Any currently active
149// request for the connection will be canceled, but this is done asynchrously, so
150// the SSE connection may still send results for the previous request. Callers
151// should take care to ignore such results. If req.Cancel is set, no new request is
153func (Webmail) Request(ctx context.Context, req Request) {
154 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
156 if !req.Cancel && req.Page.Count <= 0 {
157 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
160 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
162 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
167// ParsedMessage returns enough to render the textual body of a message. It is
168// assumed the client already has other fields through MessageItem.
169func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
170 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
172 acc := reqInfo.Account
174 xdbread(ctx, acc, func(tx *bstore.Tx) {
175 m := xmessageID(ctx, tx, msgID)
177 state := msgState{acc: acc}
180 pm, err = parsedMessage(log, &m, &state, true, false, false)
181 xcheckf(ctx, err, "parsing message")
183 if len(pm.envelope.From) == 1 {
184 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
185 xcheckf(ctx, err, "looking up view mode for from address")
191// fromAddrViewMode returns the view mode for a from address.
192func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
193 settingsViewMode := func() (store.ViewMode, error) {
194 settings := store.Settings{ID: 1}
195 if err := tx.Get(&settings); err != nil {
196 return store.ModeText, err
198 if settings.ShowHTML {
199 return store.ModeHTML, nil
201 return store.ModeText, nil
204 lp, err := smtp.ParseLocalpart(from.User)
206 return settingsViewMode()
208 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
209 fas := store.FromAddressSettings{FromAddress: fromAddr}
211 if err == bstore.ErrAbsent {
212 return settingsViewMode()
213 } else if err != nil {
214 return store.ModeText, err
216 return fas.ViewMode, nil
219// FromAddressSettingsSave saves per-"From"-address settings.
220func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
221 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
222 acc := reqInfo.Account
224 if fas.FromAddress == "" {
225 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
228 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
229 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
230 err := tx.Update(&fas)
231 xcheckf(ctx, err, "updating settings for from address")
233 err := tx.Insert(&fas)
234 xcheckf(ctx, err, "inserting settings for from address")
239// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
240// of the message in storage. Used when opening a previously saved draft message
242// If no message is find, zero is returned, not an error.
243func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
244 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
245 acc := reqInfo.Account
247 messageID, _, _ = message.MessageIDCanonical(messageID)
249 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
252 xdbread(ctx, acc, func(tx *bstore.Tx) {
253 q := bstore.QueryTx[store.Message](tx)
254 q.FilterEqual("Expunged", false)
255 q.FilterNonzero(store.Message{MessageID: messageID})
257 if err == bstore.ErrAbsent {
260 xcheckf(ctx, err, "looking up message by message-id")
266// ComposeMessage is a message to be composed, for saving draft messages.
267type ComposeMessage struct {
272 ReplyTo string // If non-empty, Reply-To header to add to message.
275 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
276 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
279// MessageCompose composes a message and saves it to the mailbox. Used for
280// saving draft messages.
281func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
282 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
283 acc := reqInfo.Account
286 log.Debug("message compose")
288 // Prevent any accidental control characters, or attempts at getting bare \r or \n
290 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
291 for _, s := range l {
292 for _, c := range s {
294 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
300 fromAddr, err := parseAddress(m.From)
301 xcheckuserf(ctx, err, "parsing From address")
303 var replyTo *message.NameAddress
305 addr, err := parseAddress(m.ReplyTo)
306 xcheckuserf(ctx, err, "parsing Reply-To address")
310 var recipients []smtp.Address
312 var toAddrs []message.NameAddress
313 for _, s := range m.To {
314 addr, err := parseAddress(s)
315 xcheckuserf(ctx, err, "parsing To address")
316 toAddrs = append(toAddrs, addr)
317 recipients = append(recipients, addr.Address)
320 var ccAddrs []message.NameAddress
321 for _, s := range m.Cc {
322 addr, err := parseAddress(s)
323 xcheckuserf(ctx, err, "parsing Cc address")
324 ccAddrs = append(ccAddrs, addr)
325 recipients = append(recipients, addr.Address)
328 var bccAddrs []message.NameAddress
329 for _, s := range m.Bcc {
330 addr, err := parseAddress(s)
331 xcheckuserf(ctx, err, "parsing Bcc address")
332 bccAddrs = append(bccAddrs, addr)
333 recipients = append(recipients, addr.Address)
336 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
338 for _, a := range recipients {
339 if a.Localpart.IsInternational() {
344 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
345 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
348 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
352 // Create file to compose message into.
353 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
354 xcheckf(ctx, err, "creating temporary file for compose message")
355 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
357 // If writing to the message file fails, we abort immediately.
358 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
364 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
365 xcheckuserf(ctx, err, "making message")
366 } else if ok && errors.Is(err, message.ErrCompose) {
367 xcheckf(ctx, err, "making message")
372 // Outer message headers.
373 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
375 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
377 xc.HeaderAddrs("To", toAddrs)
378 xc.HeaderAddrs("Cc", ccAddrs)
379 xc.HeaderAddrs("Bcc", bccAddrs)
381 xc.Subject(m.Subject)
384 // Add In-Reply-To and References headers.
385 if m.ResponseMessageID > 0 {
386 xdbread(ctx, acc, func(tx *bstore.Tx) {
387 rm := xmessageID(ctx, tx, m.ResponseMessageID)
388 msgr := acc.MessageReader(rm)
391 log.Check(err, "closing message reader")
393 rp, err := rm.LoadPart(msgr)
394 xcheckf(ctx, err, "load parsed message")
395 h, err := rp.Header()
396 xcheckf(ctx, err, "parsing header")
398 if rp.Envelope == nil {
402 if rp.Envelope.MessageID != "" {
403 xc.Header("In-Reply-To", rp.Envelope.MessageID)
405 refs := h.Values("References")
406 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
407 refs = []string{rp.Envelope.InReplyTo}
409 if rp.Envelope.MessageID != "" {
410 refs = append(refs, rp.Envelope.MessageID)
413 xc.Header("References", strings.Join(refs, "\r\n\t"))
417 xc.Header("MIME-Version", "1.0")
418 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
419 xc.Header("Content-Type", ct)
420 xc.Header("Content-Transfer-Encoding", cte)
422 xc.Write([]byte(textBody))
427 // Remove previous draft message, append message to destination mailbox.
428 acc.WithWLock(func() {
429 var changes []store.Change
433 for _, id := range newIDs {
434 p := acc.MessagePath(id)
436 log.Check(err, "removing added message aftr error", slog.String("path", p))
440 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
441 var modseq store.ModSeq // Only set if needed.
443 if m.DraftMessageID > 0 {
444 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
445 changes = append(changes, nchanges...)
448 mb, err := store.MailboxID(tx, mailboxID)
449 xcheckf(ctx, err, "looking up mailbox")
452 modseq, err = acc.NextModSeq(tx)
453 xcheckf(ctx, err, "next modseq")
460 MailboxOrigID: mb.ID,
461 Flags: store.Flags{Notjunk: true},
465 err = acc.MessageAdd(log, tx, &mb, &nm, dataFile, store.AddOpts{})
466 if err != nil && errors.Is(err, store.ErrOverQuota) {
467 xcheckuserf(ctx, err, "checking quota")
469 xcheckf(ctx, err, "storing message in mailbox")
470 newIDs = append(newIDs, nm.ID)
473 xcheckf(ctx, err, "updating sent mailbox for counts")
475 changes = append(changes, nm.ChangeAddUID(mb), mb.ChangeCounts())
479 store.BroadcastChanges(acc, changes)
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, err := store.MailboxID(tx, mailboxID)
555 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
556 xcheckuserf(ctx, err, "getting mailbox")
558 xcheckf(ctx, err, "getting mailbox")
562// xmessageID returns a non-expunged message or panics with a sherpa error.
563func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
565 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
567 m := store.Message{ID: messageID}
569 if err == bstore.ErrAbsent {
570 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
571 } else if err == nil && m.Expunged {
572 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
574 xcheckf(ctx, err, "getting message")
578func xrandomID(ctx context.Context, n int) string {
579 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
582func xrandom(ctx context.Context, n int) []byte {
583 buf := make([]byte, n)
584 x, err := cryptorand.Read(buf)
585 xcheckf(ctx, err, "read random")
587 xcheckf(ctx, errors.New("short random read"), "read random")
592// MessageSubmit sends a message by submitting it the outgoing email queue. The
593// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
594// Bcc message header.
596// If a Sent mailbox is configured, messages are added to it after submitting
597// to the delivery queue. If Bcc addresses were present, a header is prepended
598// to the message stored in the Sent mailbox.
599func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
600 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
601 acc := reqInfo.Account
604 log.Debug("message submit")
606 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
608 // todo: consider making this an HTTP POST, so we can upload as regular form, which is probably more efficient for encoding for the client and we can stream the data in. also not unlike the webapi Submit method.
610 // Prevent any accidental control characters, or attempts at getting bare \r or \n
612 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
613 for _, s := range l {
614 for _, c := range s {
616 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
622 fromAddr, err := parseAddress(m.From)
623 xcheckuserf(ctx, err, "parsing From address")
625 var replyTo *message.NameAddress
627 a, err := parseAddress(m.ReplyTo)
628 xcheckuserf(ctx, err, "parsing Reply-To address")
632 var recipients []smtp.Address
634 var toAddrs []message.NameAddress
635 for _, s := range m.To {
636 addr, err := parseAddress(s)
637 xcheckuserf(ctx, err, "parsing To address")
638 toAddrs = append(toAddrs, addr)
639 recipients = append(recipients, addr.Address)
642 var ccAddrs []message.NameAddress
643 for _, s := range m.Cc {
644 addr, err := parseAddress(s)
645 xcheckuserf(ctx, err, "parsing Cc address")
646 ccAddrs = append(ccAddrs, addr)
647 recipients = append(recipients, addr.Address)
650 var bccAddrs []message.NameAddress
651 for _, s := range m.Bcc {
652 addr, err := parseAddress(s)
653 xcheckuserf(ctx, err, "parsing Bcc address")
654 bccAddrs = append(bccAddrs, addr)
655 recipients = append(recipients, addr.Address)
658 // Check if from address is allowed for account.
659 if ok, disabled := mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address); disabled {
660 metricSubmission.WithLabelValues("domaindisabled").Inc()
661 xcheckuserf(ctx, mox.ErrDomainDisabled, `looking up "from" address for account`)
663 metricSubmission.WithLabelValues("badfrom").Inc()
664 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
667 if len(recipients) == 0 {
668 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
671 // Check outgoing message rate limit.
672 xdbread(ctx, acc, func(tx *bstore.Tx) {
673 rcpts := make([]smtp.Path, len(recipients))
674 for i, r := range recipients {
675 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
677 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
679 metricSubmission.WithLabelValues("messagelimiterror").Inc()
680 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
681 } else if rcptlimit >= 0 {
682 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
683 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
685 xcheckf(ctx, err, "checking send limit")
688 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
690 for _, a := range recipients {
691 if a.Localpart.IsInternational() {
696 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
697 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
700 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
704 // Create file to compose message into.
705 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
706 xcheckf(ctx, err, "creating temporary file for message")
707 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
709 // If writing to the message file fails, we abort immediately.
710 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
716 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
717 xcheckuserf(ctx, err, "making message")
718 } else if ok && errors.Is(err, message.ErrCompose) {
719 xcheckf(ctx, err, "making message")
724 // 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
726 // Each queued message gets a Received header.
727 // We don't have access to the local IP for adding.
728 // We cannot use VIA, because there is no registered method. We would like to use
729 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
730 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
731 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
732 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
733 recvHdrFor := func(rcptTo string) string {
734 recvHdr := &message.HeaderWriter{}
735 // For additional Received-header clauses, see:
736 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
737 // Note: we don't have "via" or "with", there is no registered for webmail.
738 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
739 if reqInfo.Request.TLS != nil {
740 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
742 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
743 return recvHdr.String()
746 // Outer message headers.
747 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
749 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
751 xc.HeaderAddrs("To", toAddrs)
752 xc.HeaderAddrs("Cc", ccAddrs)
753 // We prepend Bcc headers to the message when adding to the Sent mailbox.
755 xc.Subject(m.Subject)
758 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
759 xc.Header("Message-Id", messageID)
760 xc.Header("Date", time.Now().Format(message.RFC5322Z))
761 // Add In-Reply-To and References headers.
762 if m.ResponseMessageID > 0 {
763 xdbread(ctx, acc, func(tx *bstore.Tx) {
764 rm := xmessageID(ctx, tx, m.ResponseMessageID)
765 msgr := acc.MessageReader(rm)
768 log.Check(err, "closing message reader")
770 rp, err := rm.LoadPart(msgr)
771 xcheckf(ctx, err, "load parsed message")
772 h, err := rp.Header()
773 xcheckf(ctx, err, "parsing header")
775 if rp.Envelope == nil {
779 if rp.Envelope.MessageID != "" {
780 xc.Header("In-Reply-To", rp.Envelope.MessageID)
782 refs := h.Values("References")
783 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
784 refs = []string{rp.Envelope.InReplyTo}
786 if rp.Envelope.MessageID != "" {
787 refs = append(refs, rp.Envelope.MessageID)
790 xc.Header("References", strings.Join(refs, "\r\n\t"))
794 if m.UserAgent != "" {
795 xc.Header("User-Agent", m.UserAgent)
797 if m.RequireTLS != nil && !*m.RequireTLS {
798 xc.Header("TLS-Required", "No")
800 xc.Header("MIME-Version", "1.0")
802 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
803 mp := multipart.NewWriter(xc)
804 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
807 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
808 textHdr := textproto.MIMEHeader{}
809 textHdr.Set("Content-Type", ct)
810 textHdr.Set("Content-Transfer-Encoding", cte)
812 textp, err := mp.CreatePart(textHdr)
813 xcheckf(ctx, err, "adding text part to message")
814 _, err = textp.Write(textBody)
815 xcheckf(ctx, err, "writing text part")
817 xaddPart := func(ct, filename string) io.Writer {
818 ahdr := textproto.MIMEHeader{}
819 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
821 ahdr.Set("Content-Type", ct)
822 ahdr.Set("Content-Transfer-Encoding", "base64")
823 ahdr.Set("Content-Disposition", cd)
824 ap, err := mp.CreatePart(ahdr)
825 xcheckf(ctx, err, "adding attachment part to message")
829 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
830 ap := xaddPart(ct, filename)
832 for len(base64Data) > 0 {
835 line, base64Data = base64Data[:n], base64Data[n:]
836 _, err := ap.Write(line)
837 xcheckf(ctx, err, "writing attachment")
838 _, err = ap.Write([]byte("\r\n"))
839 xcheckf(ctx, err, "writing attachment")
843 xaddAttachment := func(ct, filename string, r io.Reader) {
844 ap := xaddPart(ct, filename)
845 wc := moxio.Base64Writer(ap)
846 _, err := io.Copy(wc, r)
847 xcheckf(ctx, err, "adding attachment")
849 xcheckf(ctx, err, "flushing attachment")
852 for _, a := range m.Attachments {
854 if !strings.HasPrefix(s, "data:") {
855 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
858 t := strings.SplitN(s, ",", 2)
860 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
862 if !strings.HasSuffix(t[0], "base64") {
863 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
865 ct := strings.TrimSuffix(t[0], "base64")
866 ct = strings.TrimSuffix(ct, ";")
868 ct = "application/octet-stream"
870 filename := a.Filename
872 filename = "unnamed.bin"
874 params := map[string]string{"name": filename}
875 ct = mime.FormatMediaType(ct, params)
877 // Ensure base64 is valid, then we'll write the original string.
878 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
879 xcheckuserf(ctx, err, "parsing attachment as base64")
881 xaddAttachmentBase64(ct, filename, []byte(t[1]))
884 if len(m.ForwardAttachments.Paths) > 0 {
885 acc.WithRLock(func() {
886 xdbread(ctx, acc, func(tx *bstore.Tx) {
887 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
888 msgr := acc.MessageReader(fm)
891 log.Check(err, "closing message reader")
894 fp, err := fm.LoadPart(msgr)
895 xcheckf(ctx, err, "load parsed message")
897 for _, path := range m.ForwardAttachments.Paths {
899 for _, xp := range path {
900 if xp < 0 || xp >= len(ap.Parts) {
901 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
906 _, filename, err := ap.DispositionFilename()
907 if err != nil && errors.Is(err, message.ErrParamEncoding) {
908 log.Debugx("parsing disposition/filename", err)
910 xcheckf(ctx, err, "reading disposition")
913 filename = "unnamed.bin"
915 params := map[string]string{"name": filename}
916 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
917 params["charset"] = pcharset
919 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
920 ct = mime.FormatMediaType(ct, params)
921 xaddAttachment(ct, filename, ap.Reader())
928 xcheckf(ctx, err, "writing mime multipart")
930 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
931 xc.Header("Content-Type", ct)
932 xc.Header("Content-Transfer-Encoding", cte)
934 xc.Write([]byte(textBody))
939 // Add DKIM-Signature headers.
941 fd := fromAddr.Address.Domain
942 confDom, _ := mox.Conf.Domain(fd)
943 if confDom.Disabled {
944 xcheckuserf(ctx, mox.ErrDomainDisabled, "checking domain")
946 selectors := mox.DKIMSelectors(confDom.DKIM)
947 if len(selectors) > 0 {
948 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
950 metricServerErrors.WithLabelValues("dkimsign").Inc()
952 xcheckf(ctx, err, "sign dkim")
954 msgPrefix = dkimHeaders
957 accConf, _ := acc.Conf()
958 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
959 xcheckf(ctx, err, "parsing login address")
960 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
961 fromPath := fromAddr.Address.Path()
962 var localpartBase string
964 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0]
966 qml := make([]queue.Msg, len(recipients))
968 for i, rcpt := range recipients {
972 fromID = xrandomID(ctx, 16)
973 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromID)
976 // Don't use per-recipient unique message prefix when multiple recipients are
977 // present, or the queue cannot deliver it in a single smtp transaction.
979 if len(recipients) == 1 {
980 recvRcpt = rcpt.Pack(smtputf8)
982 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
983 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
985 Localpart: rcpt.Localpart,
986 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
988 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
989 if m.FutureRelease != nil {
990 ival := time.Until(*m.FutureRelease)
992 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
993 } else if ival > queue.FutureReleaseIntervalMax {
994 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
996 qm.NextAttempt = *m.FutureRelease
997 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
998 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
1001 // no qm.Extra from webmail
1004 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
1006 metricSubmission.WithLabelValues("queueerror").Inc()
1008 xcheckf(ctx, err, "adding messages to the delivery queue")
1009 metricSubmission.WithLabelValues("ok").Inc()
1011 var modseq store.ModSeq // Only set if needed.
1013 // We have committed to sending the message. We want to follow through
1014 // with appending to Sent and removing the draft message.
1015 ctx = context.WithoutCancel(ctx)
1017 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1018 // remove any draft message.
1019 acc.WithWLock(func() {
1020 var changes []store.Change
1024 if x := recover(); x != nil {
1026 metricServerErrors.WithLabelValues("submit").Inc()
1034 for _, id := range newIDs {
1035 p := acc.MessagePath(id)
1037 log.Check(err, "removing delivered message on error", slog.String("path", p))
1041 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1042 if m.DraftMessageID > 0 {
1043 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
1044 changes = append(changes, nchanges...)
1047 if m.ResponseMessageID > 0 {
1048 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1055 if !rm.Junk && !rm.Notjunk {
1058 if rm.Flags != oflags {
1060 modseq, err = acc.NextModSeq(tx)
1061 xcheckf(ctx, err, "next modseq")
1064 err := tx.Update(&rm)
1065 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1067 // Update modseq of mailbox of replied/forwarded message.
1068 rmb, err := store.MailboxID(tx, rm.MailboxID)
1069 xcheckf(ctx, err, "get mailbox of replied/forwarded message for modseq update")
1071 err = tx.Update(&rmb)
1072 xcheckf(ctx, err, "update modseq of mailbox of replied/forwarded message")
1074 changes = append(changes, rm.ChangeFlags(oflags, rmb))
1076 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm})
1077 xcheckf(ctx, err, "retraining messages after reply/forward")
1080 // Move messages from this thread still in this mailbox to the designated Archive
1082 if m.ArchiveThread {
1083 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Archive", true).Get()
1084 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1085 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1087 xcheckf(ctx, err, "looking up designated archive mailbox")
1090 q := bstore.QueryTx[store.Message](tx)
1091 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1092 q.FilterEqual("Expunged", false)
1093 err = q.IDs(&msgIDs)
1094 xcheckf(ctx, err, "listing messages in thread to archive")
1095 if len(msgIDs) > 0 {
1096 ids, nchanges := xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, &modseq)
1097 newIDs = append(newIDs, ids...)
1098 changes = append(changes, nchanges...)
1103 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1104 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1105 // There is no mailbox designated as Sent mailbox, so we're done.
1108 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1111 modseq, err = acc.NextModSeq(tx)
1112 xcheckf(ctx, err, "next modseq")
1115 // If there were bcc headers, prepend those to the stored message only, before the
1116 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1117 // message won't validate with DKIM anymore, which is fine.
1118 if len(bccAddrs) > 0 {
1119 var sb strings.Builder
1120 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1121 xbcc.HeaderAddrs("Bcc", bccAddrs)
1123 msgPrefix = sb.String() + msgPrefix
1126 sentm := store.Message{
1129 MailboxID: sentmb.ID,
1130 MailboxOrigID: sentmb.ID,
1131 Flags: store.Flags{Notjunk: true, Seen: true},
1132 Size: int64(len(msgPrefix)) + xc.Size,
1133 MsgPrefix: []byte(msgPrefix),
1136 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1137 if err != nil && errors.Is(err, store.ErrOverQuota) {
1138 xcheckuserf(ctx, err, "checking quota")
1139 } else if err != nil {
1140 metricSubmission.WithLabelValues("storesenterror").Inc()
1143 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1144 newIDs = append(newIDs, sentm.ID)
1146 err = tx.Update(&sentmb)
1147 xcheckf(ctx, err, "updating sent mailbox for counts")
1149 changes = append(changes, sentm.ChangeAddUID(sentmb), sentmb.ChangeCounts())
1153 store.BroadcastChanges(acc, changes)
1157// MessageMove moves messages to another mailbox. If the message is already in
1158// the mailbox an error is returned.
1159func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1160 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1161 acc := reqInfo.Account
1164 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1167var xops = webops.XOps{
1170 Checkuserf: xcheckuserf,
1173// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1174func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1175 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1176 acc := reqInfo.Account
1179 if len(messageIDs) == 0 {
1183 xops.MessageDelete(ctx, log, acc, messageIDs)
1186// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1187// flags should be lower-case, but will be converted and verified.
1188func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1189 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1190 acc := reqInfo.Account
1193 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1196// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1197func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1198 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1199 acc := reqInfo.Account
1202 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1205// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1206// not automatically included, they must explicitly be included in the list of IDs.
1207func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1208 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1209 acc := reqInfo.Account
1212 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1215// MailboxCreate creates a new mailbox.
1216func (Webmail) MailboxCreate(ctx context.Context, name string) {
1217 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1218 acc := reqInfo.Account
1221 name, _, err = store.CheckMailboxName(name, false)
1222 xcheckuserf(ctx, err, "checking mailbox name")
1224 acc.WithWLock(func() {
1225 var changes []store.Change
1226 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1229 _, changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1231 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1233 xcheckf(ctx, err, "creating mailbox")
1236 store.BroadcastChanges(acc, changes)
1240// MailboxDelete deletes a mailbox and all its messages and annotations.
1241func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1242 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1243 acc := reqInfo.Account
1246 acc.WithWLock(func() {
1247 var changes []store.Change
1249 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1250 mb := xmailboxID(ctx, tx, mailboxID)
1251 if mb.Name == "Inbox" {
1252 // Inbox is special in IMAP and cannot be removed.
1253 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1256 var hasChildren bool
1258 changes, hasChildren, err = acc.MailboxDelete(ctx, log, tx, &mb)
1260 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1262 xcheckf(ctx, err, "deleting mailbox")
1265 store.BroadcastChanges(acc, changes)
1269// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1270// its child mailboxes.
1271func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1272 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1273 acc := reqInfo.Account
1276 acc.WithWLock(func() {
1277 var changes []store.Change
1279 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1280 mb := xmailboxID(ctx, tx, mailboxID)
1282 qm := bstore.QueryTx[store.Message](tx)
1283 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1284 qm.FilterEqual("Expunged", false)
1287 xcheckf(ctx, err, "listing messages to remove")
1290 xcheckuserf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1293 modseq, err := acc.NextModSeq(tx)
1294 xcheckf(ctx, err, "next modseq")
1296 chrem, chmbcounts, err := acc.MessageRemove(log, tx, modseq, &mb, store.RemoveOpts{}, l...)
1297 xcheckf(ctx, err, "expunge messages")
1298 changes = append(changes, chrem, chmbcounts)
1300 err = tx.Update(&mb)
1301 xcheckf(ctx, err, "updating mailbox for counts")
1304 store.BroadcastChanges(acc, changes)
1308// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1309// ID and its messages are unchanged.
1310func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1311 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1312 acc := reqInfo.Account
1314 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1315 // standard. We can just say no.
1317 newName, _, err = store.CheckMailboxName(newName, false)
1318 xcheckuserf(ctx, err, "checking new mailbox name")
1320 acc.WithWLock(func() {
1321 var changes []store.Change
1323 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1324 mbsrc := xmailboxID(ctx, tx, mailboxID)
1326 var isInbox, alreadyExists bool
1327 var modseq store.ModSeq
1328 changes, isInbox, alreadyExists, err = acc.MailboxRename(tx, &mbsrc, newName, &modseq)
1329 if isInbox || alreadyExists {
1330 xcheckuserf(ctx, err, "renaming mailbox")
1332 xcheckf(ctx, err, "renaming mailbox")
1335 store.BroadcastChanges(acc, changes)
1339// CompleteRecipient returns autocomplete matches for a recipient, returning the
1340// matches, most recently used first, and whether this is the full list and further
1341// requests for longer prefixes aren't necessary.
1342func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1343 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1344 acc := reqInfo.Account
1346 search = strings.ToLower(search)
1348 var matches []string
1350 acc.WithRLock(func() {
1351 xdbread(ctx, acc, func(tx *bstore.Tx) {
1356 seen := map[key]bool{}
1358 q := bstore.QueryTx[store.Recipient](tx)
1360 err := q.ForEach(func(r store.Recipient) error {
1361 k := key{r.Localpart, r.Domain}
1365 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1366 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1367 if !strings.Contains(strings.ToLower(address), search) {
1370 if len(matches) >= 20 {
1372 return bstore.StopForEach
1375 // Look in the message that was sent for a name along with the address.
1376 m := store.Message{ID: r.MessageID}
1378 xcheckf(ctx, err, "get sent message")
1379 if !m.Expunged && m.ParsedBuf != nil {
1380 var part message.Part
1381 err := json.Unmarshal(m.ParsedBuf, &part)
1382 xcheckf(ctx, err, "parsing part")
1384 dom, err := dns.ParseDomain(r.Domain)
1385 xcheckf(ctx, err, "parsing domain of recipient")
1389 checkAddrs := func(l []message.Address) {
1393 for _, a := range l {
1394 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1396 address = addressString(a, false)
1401 if part.Envelope != nil {
1402 env := part.Envelope
1409 matches = append(matches, address)
1413 xcheckf(ctx, err, "listing recipients")
1419// addressString returns an address into a string as it could be used in a message header.
1420func addressString(a message.Address, smtputf8 bool) string {
1422 dom, err := dns.ParseDomain(a.Host)
1424 if smtputf8 && dom.Unicode != "" {
1431 return "<" + a.User + "@" + host + ">"
1434 const atom = "!#$%&'*+-/=?^_`{|}~"
1436 for _, c := range a.Name {
1437 if c == '\t' || c == ' ' || c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsAny(string(c), atom) {
1440 // We need to quote.
1442 for _, c := range a.Name {
1443 if c == '\\' || c == '"' {
1451 return name + " <" + a.User + "@" + host + ">"
1454// MailboxSetSpecialUse sets the special use flags of a mailbox.
1455func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1456 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1457 acc := reqInfo.Account
1459 acc.WithWLock(func() {
1460 var changes []store.Change
1462 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1463 xmb := xmailboxID(ctx, tx, mb.ID)
1465 modseq, err := acc.NextModSeq(tx)
1466 xcheckf(ctx, err, "get next modseq")
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.UpdateFields(map[string]any{specialUse: false, "ModSeq": modseq})
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
1494 err = tx.Update(&xmb)
1495 xcheckf(ctx, err, "updating special-use flags for mailbox")
1496 changes = append(changes, xmb.ChangeSpecialUse())
1499 store.BroadcastChanges(acc, changes)
1503// ThreadCollapse saves the ThreadCollapse field for the messages and its
1504// children. The messageIDs are typically thread roots. But not all roots
1505// (without parent) of a thread need to have the same collapsed state.
1506func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1507 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1508 acc := reqInfo.Account
1510 if len(messageIDs) == 0 {
1511 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1514 acc.WithWLock(func() {
1515 changes := make([]store.Change, 0, len(messageIDs))
1516 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1517 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1518 // (child) messages. Further refined in FilterFn.
1519 threadIDs := map[int64]struct{}{}
1520 msgIDs := map[int64]struct{}{}
1521 for _, id := range messageIDs {
1522 m := store.Message{ID: id}
1524 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1525 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1527 xcheckf(ctx, err, "get message")
1528 threadIDs[m.ThreadID] = struct{}{}
1529 msgIDs[id] = struct{}{}
1532 var updated []store.Message
1533 q := bstore.QueryTx[store.Message](tx)
1534 q.FilterEqual("Expunged", false)
1535 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1536 q.FilterNotEqual("ThreadCollapsed", collapse)
1537 q.FilterFn(func(tm store.Message) bool {
1538 for _, id := range tm.ThreadParentIDs {
1539 if _, ok := msgIDs[id]; ok {
1543 _, ok := msgIDs[tm.ID]
1547 q.SortAsc("ID") // Consistent order for testing.
1548 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1549 xcheckf(ctx, err, "updating collapse in database")
1551 for _, m := range updated {
1552 changes = append(changes, m.ChangeThread())
1555 store.BroadcastChanges(acc, changes)
1559// ThreadMute saves the ThreadMute field for the messages and their children.
1560// If messages are muted, they are also marked collapsed.
1561func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1562 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1563 acc := reqInfo.Account
1565 if len(messageIDs) == 0 {
1566 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1569 acc.WithWLock(func() {
1570 changes := make([]store.Change, 0, len(messageIDs))
1571 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1572 threadIDs := map[int64]struct{}{}
1573 msgIDs := map[int64]struct{}{}
1574 for _, id := range messageIDs {
1575 m := store.Message{ID: id}
1577 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1578 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1580 xcheckf(ctx, err, "get message")
1581 threadIDs[m.ThreadID] = struct{}{}
1582 msgIDs[id] = struct{}{}
1585 var updated []store.Message
1587 q := bstore.QueryTx[store.Message](tx)
1588 q.FilterEqual("Expunged", false)
1589 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1590 q.FilterFn(func(tm store.Message) bool {
1591 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1594 for _, id := range tm.ThreadParentIDs {
1595 if _, ok := msgIDs[id]; ok {
1599 _, ok := msgIDs[tm.ID]
1603 fields := map[string]any{"ThreadMuted": mute}
1605 fields["ThreadCollapsed"] = true
1607 _, err := q.UpdateFields(fields)
1608 xcheckf(ctx, err, "updating mute in database")
1610 for _, m := range updated {
1611 changes = append(changes, m.ChangeThread())
1614 store.BroadcastChanges(acc, changes)
1618// SecurityResult indicates whether a security feature is supported.
1619type SecurityResult string
1622 SecurityResultError SecurityResult = "error"
1623 SecurityResultNo SecurityResult = "no"
1624 SecurityResultYes SecurityResult = "yes"
1625 // Unknown whether supported. Finding out may only be (reasonably) possible when
1626 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1628 SecurityResultUnknown SecurityResult = "unknown"
1631// RecipientSecurity is a quick analysis of the security properties of delivery to
1632// the recipient (domain).
1633type RecipientSecurity struct {
1634 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1635 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1637 STARTTLS SecurityResult
1639 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1641 MTASTS SecurityResult
1643 // Whether MX lookup response was DNSSEC-signed.
1644 DNSSEC SecurityResult
1646 // Whether first delivery destination has DANE records.
1649 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1650 // Will be "unknown" if no delivery to the domain has been attempted yet.
1651 RequireTLS SecurityResult
1654// RecipientSecurity looks up security properties of the address in the
1655// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1656func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1657 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1660 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1661 return recipientSecurity(ctx, log, resolver, messageAddressee)
1664// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1665func logPanic(ctx context.Context) {
1670 log := pkglog.WithContext(ctx)
1671 log.Error("recover from panic", slog.Any("panic", x))
1673 metrics.PanicInc(metrics.Webmail)
1676// separate function for testing with mocked resolver.
1677func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1678 rs := RecipientSecurity{
1679 SecurityResultUnknown,
1680 SecurityResultUnknown,
1681 SecurityResultUnknown,
1682 SecurityResultUnknown,
1683 SecurityResultUnknown,
1686 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1687 msgAddr, err := parser.Parse(messageAddressee)
1689 return rs, fmt.Errorf("parsing addressee: %v", err)
1691 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1693 return rs, fmt.Errorf("parsing address: %v", err)
1696 var wg sync.WaitGroup
1704 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1705 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1706 rs.MTASTS = SecurityResultYes
1707 } else if err == nil {
1708 rs.MTASTS = SecurityResultNo
1710 rs.MTASTS = SecurityResultError
1720 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1722 rs.DNSSEC = SecurityResultError
1725 if origNextHopAuthentic && expandedNextHopAuthentic {
1726 rs.DNSSEC = SecurityResultYes
1728 rs.DNSSEC = SecurityResultNo
1731 if !origNextHopAuthentic {
1732 rs.DANE = SecurityResultNo
1736 // We're only looking at the first host to deliver to (typically first mx destination).
1737 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1738 return // Should not happen.
1742 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1743 // error result instead of no-DANE result.
1744 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1746 rs.DANE = SecurityResultError
1750 rs.DANE = SecurityResultNo
1754 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1756 rs.DANE = SecurityResultError
1758 } else if daneRequired {
1759 rs.DANE = SecurityResultYes
1761 rs.DANE = SecurityResultNo
1765 // STARTTLS and RequireTLS
1766 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1767 acc := reqInfo.Account
1769 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1770 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1771 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1773 if err == bstore.ErrAbsent {
1775 } else if err != nil {
1776 rs.STARTTLS = SecurityResultError
1777 rs.RequireTLS = SecurityResultError
1778 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1782 rs.STARTTLS = SecurityResultYes
1784 rs.STARTTLS = SecurityResultNo
1787 rs.RequireTLS = SecurityResultYes
1789 rs.RequireTLS = SecurityResultNo
1793 xcheckf(ctx, err, "lookup recipient domain")
1800// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1801func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1802 s, err := wordDecoder.DecodeHeader(text)
1803 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1807// SettingsSave saves settings, e.g. for composing.
1808func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1809 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1810 acc := reqInfo.Account
1813 err := acc.DB.Update(ctx, &settings)
1814 xcheckf(ctx, err, "save settings")
1817func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1818 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1819 acc := reqInfo.Account
1822 xdbread(ctx, acc, func(tx *bstore.Tx) {
1823 m := xmessageID(ctx, tx, msgID)
1824 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1825 mbDst := xmailboxID(ctx, tx, mbDstID)
1827 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1830 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1832 conf, _ := acc.Conf()
1833 dest := conf.Destinations[rcptTo] // May not be present.
1834 defaultMailbox := "Inbox"
1835 if dest.Mailbox != "" {
1836 defaultMailbox = dest.Mailbox
1839 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1840 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1844 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1845 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1846 xcheckf(ctx, err, "looking up previous response for source mailbox")
1850 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1851 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1856 // Parse message for List-Id header.
1857 state := msgState{acc: acc}
1859 pm, err := parsedMessage(log, &m, &state, true, false, false)
1860 xcheckf(ctx, err, "parsing message")
1862 // The suggested ruleset. Once all is checked, we'll return it.
1863 var nrs *config.Ruleset
1865 // If List-Id header is present, we'll treat it as a (mailing) list message.
1866 if l, ok := pm.Headers["List-Id"]; ok {
1868 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1871 var listIDDom dns.Domain
1872 listID, listIDDom = parseListID(l[0])
1874 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1878 // Check if we have a previous "No" answer for this list-id.
1879 no := store.RulesetNoListID{
1880 RcptToAddress: rcptTo,
1882 ToInbox: mbDst.Name == "Inbox",
1884 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1885 xcheckf(ctx, err, "looking up previous response for list-id")
1890 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1891 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1892 // are subscribed to, and take advantage of any reduced junk filtering.
1893 listIDDomStr := listIDDom.Name()
1895 doms := m.DKIMDomains
1896 if m.MailFromValidated {
1897 doms = append(doms, m.MailFromDomain)
1899 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1900 // of SPF verification of one host.
1901 sort.Slice(doms, func(i, j int) bool {
1902 return len(doms[i]) < len(doms[j])
1904 var listAllowDom string
1905 for _, dom := range doms {
1906 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1911 if listAllowDom == "" {
1915 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1916 nrs = &config.Ruleset{
1917 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1918 ListAllowDomain: listAllowDom,
1919 Mailbox: mbDst.Name,
1922 // Otherwise, try to make a rule based on message "From" address.
1923 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1926 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1928 no := store.RulesetNoMsgFrom{
1929 RcptToAddress: rcptTo,
1930 MsgFromAddress: msgFrom,
1931 ToInbox: mbDst.Name == "Inbox",
1933 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1934 xcheckf(ctx, err, "looking up previous response for message from address")
1939 nrs = &config.Ruleset{
1940 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1941 Mailbox: mbDst.Name,
1945 // Only suggest adding/removing rule if it isn't/is present.
1947 for _, rs := range dest.Rulesets {
1948 xrs := config.Ruleset{
1949 MsgFromRegexp: rs.MsgFromRegexp,
1950 HeadersRegexp: rs.HeadersRegexp,
1951 ListAllowDomain: rs.ListAllowDomain,
1952 Mailbox: nrs.Mailbox,
1954 if xrs.Equal(*nrs) {
1959 isRemove = mbDst.Name == defaultMailbox
1961 nrs.Mailbox = mbSrc.Name
1963 if isRemove && !have || !isRemove && have {
1967 // We'll be returning a suggested ruleset.
1968 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1974// Parse the list-id value (the value between <>) from a list-id header.
1975// Returns an empty string if it couldn't be parsed.
1976func parseListID(s string) (listID string, dom dns.Domain) {
1978 s = strings.TrimRight(s, " \t")
1979 if !strings.HasSuffix(s, ">") {
1980 return "", dns.Domain{}
1983 t := strings.Split(s, "<")
1985 return "", dns.Domain{}
1988 dom, err := dns.ParseDomain(s)
1995func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1996 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1998 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1999 dest, ok := acc.Destinations[rcptTo]
2001 // todo: we could find the catchall address and add the rule, or add the address explicitly.
2002 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")
2005 nd := map[string]config.Destination{}
2006 for addr, d := range acc.Destinations {
2009 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2011 acc.Destinations = nd
2013 xcheckf(ctx, err, "saving account with new ruleset")
2016func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2017 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2019 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2020 dest, ok := acc.Destinations[rcptTo]
2022 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2025 nd := map[string]config.Destination{}
2026 for addr, d := range acc.Destinations {
2029 var l []config.Ruleset
2031 for _, rs := range dest.Rulesets {
2032 if rs.Equal(ruleset) {
2039 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2043 acc.Destinations = nd
2045 xcheckf(ctx, err, "saving account with new ruleset")
2048func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2049 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2050 acc := reqInfo.Account
2054 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2056 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2058 xcheckf(ctx, err, "storing user response")
2061func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2062 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2063 acc := reqInfo.Account
2065 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2066 xcheckf(ctx, err, "storing user response")
2069func slicesAny[T any](l []T) []any {
2070 r := make([]any, len(l))
2071 for i, v := range l {
2077// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2078func (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) {