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.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 {
834 n := min(len(line), 78)
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")
1066 changes = append(changes, rm.ChangeFlags(oflags))
1068 // Update modseq of mailbox of replied/forwarded message.
1069 rmb, err := store.MailboxID(tx, rm.MailboxID)
1070 xcheckf(ctx, err, "get mailbox of replied/forwarded message for modseq update")
1072 err = tx.Update(&rmb)
1073 xcheckf(ctx, err, "update modseq of mailbox of replied/forwarded message")
1075 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm})
1076 xcheckf(ctx, err, "retraining messages after reply/forward")
1079 // Move messages from this thread still in this mailbox to the designated Archive
1081 if m.ArchiveThread {
1082 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Archive", true).Get()
1083 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1084 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1086 xcheckf(ctx, err, "looking up designated archive mailbox")
1089 q := bstore.QueryTx[store.Message](tx)
1090 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1091 q.FilterEqual("Expunged", false)
1092 err = q.IDs(&msgIDs)
1093 xcheckf(ctx, err, "listing messages in thread to archive")
1094 if len(msgIDs) > 0 {
1095 ids, nchanges := xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, &modseq)
1096 newIDs = append(newIDs, ids...)
1097 changes = append(changes, nchanges...)
1102 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1103 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1104 // There is no mailbox designated as Sent mailbox, so we're done.
1107 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1110 modseq, err = acc.NextModSeq(tx)
1111 xcheckf(ctx, err, "next modseq")
1114 // If there were bcc headers, prepend those to the stored message only, before the
1115 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1116 // message won't validate with DKIM anymore, which is fine.
1117 if len(bccAddrs) > 0 {
1118 var sb strings.Builder
1119 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1120 xbcc.HeaderAddrs("Bcc", bccAddrs)
1122 msgPrefix = sb.String() + msgPrefix
1125 sentm := store.Message{
1128 MailboxID: sentmb.ID,
1129 MailboxOrigID: sentmb.ID,
1130 Flags: store.Flags{Notjunk: true, Seen: true},
1131 Size: int64(len(msgPrefix)) + xc.Size,
1132 MsgPrefix: []byte(msgPrefix),
1135 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1136 if err != nil && errors.Is(err, store.ErrOverQuota) {
1137 xcheckuserf(ctx, err, "checking quota")
1138 } else if err != nil {
1139 metricSubmission.WithLabelValues("storesenterror").Inc()
1142 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1143 newIDs = append(newIDs, sentm.ID)
1145 err = tx.Update(&sentmb)
1146 xcheckf(ctx, err, "updating sent mailbox for counts")
1148 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1152 store.BroadcastChanges(acc, changes)
1156// MessageMove moves messages to another mailbox. If the message is already in
1157// the mailbox an error is returned.
1158func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1159 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1160 acc := reqInfo.Account
1163 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1166var xops = webops.XOps{
1169 Checkuserf: xcheckuserf,
1172// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1173func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1174 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1175 acc := reqInfo.Account
1178 if len(messageIDs) == 0 {
1182 xops.MessageDelete(ctx, log, acc, messageIDs)
1185// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1186// flags should be lower-case, but will be converted and verified.
1187func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1188 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1189 acc := reqInfo.Account
1192 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1195// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1196func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1197 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1198 acc := reqInfo.Account
1201 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1204// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1205// not automatically included, they must explicitly be included in the list of IDs.
1206func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1207 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1208 acc := reqInfo.Account
1211 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1214// MailboxCreate creates a new mailbox.
1215func (Webmail) MailboxCreate(ctx context.Context, name string) {
1216 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1217 acc := reqInfo.Account
1220 name, _, err = store.CheckMailboxName(name, false)
1221 xcheckuserf(ctx, err, "checking mailbox name")
1223 acc.WithWLock(func() {
1224 var changes []store.Change
1225 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1228 _, changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1230 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1232 xcheckf(ctx, err, "creating mailbox")
1235 store.BroadcastChanges(acc, changes)
1239// MailboxDelete deletes a mailbox and all its messages and annotations.
1240func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1241 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1242 acc := reqInfo.Account
1245 acc.WithWLock(func() {
1246 var changes []store.Change
1248 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1249 mb := xmailboxID(ctx, tx, mailboxID)
1250 if mb.Name == "Inbox" {
1251 // Inbox is special in IMAP and cannot be removed.
1252 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1255 var hasChildren bool
1257 changes, hasChildren, err = acc.MailboxDelete(ctx, log, tx, &mb)
1259 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1261 xcheckf(ctx, err, "deleting mailbox")
1264 store.BroadcastChanges(acc, changes)
1268// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1269// its child mailboxes.
1270func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1271 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1272 acc := reqInfo.Account
1275 acc.WithWLock(func() {
1276 var changes []store.Change
1278 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1279 mb := xmailboxID(ctx, tx, mailboxID)
1281 qm := bstore.QueryTx[store.Message](tx)
1282 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1283 qm.FilterEqual("Expunged", false)
1286 xcheckf(ctx, err, "listing messages to remove")
1289 xcheckuserf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1292 modseq, err := acc.NextModSeq(tx)
1293 xcheckf(ctx, err, "next modseq")
1295 chrem, chmbcounts, err := acc.MessageRemove(log, tx, modseq, &mb, store.RemoveOpts{}, l...)
1296 xcheckf(ctx, err, "expunge messages")
1297 changes = append(changes, chrem, chmbcounts)
1299 err = tx.Update(&mb)
1300 xcheckf(ctx, err, "updating mailbox for counts")
1303 store.BroadcastChanges(acc, changes)
1307// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1308// ID and its messages are unchanged.
1309func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1310 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1311 acc := reqInfo.Account
1313 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1314 // standard. We can just say no.
1316 newName, _, err = store.CheckMailboxName(newName, false)
1317 xcheckuserf(ctx, err, "checking new mailbox name")
1319 acc.WithWLock(func() {
1320 var changes []store.Change
1322 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1323 mbsrc := xmailboxID(ctx, tx, mailboxID)
1325 var isInbox, alreadyExists bool
1326 var modseq store.ModSeq
1327 changes, isInbox, alreadyExists, err = acc.MailboxRename(tx, &mbsrc, newName, &modseq)
1328 if isInbox || alreadyExists {
1329 xcheckuserf(ctx, err, "renaming mailbox")
1331 xcheckf(ctx, err, "renaming mailbox")
1334 store.BroadcastChanges(acc, changes)
1338// CompleteRecipient returns autocomplete matches for a recipient, returning the
1339// matches, most recently used first, and whether this is the full list and further
1340// requests for longer prefixes aren't necessary.
1341func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1342 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1343 acc := reqInfo.Account
1345 search = strings.ToLower(search)
1347 var matches []string
1349 acc.WithRLock(func() {
1350 xdbread(ctx, acc, func(tx *bstore.Tx) {
1355 seen := map[key]bool{}
1357 q := bstore.QueryTx[store.Recipient](tx)
1359 err := q.ForEach(func(r store.Recipient) error {
1360 k := key{r.Localpart, r.Domain}
1364 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1365 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1366 if !strings.Contains(strings.ToLower(address), search) {
1369 if len(matches) >= 20 {
1371 return bstore.StopForEach
1374 // Look in the message that was sent for a name along with the address.
1375 m := store.Message{ID: r.MessageID}
1377 xcheckf(ctx, err, "get sent message")
1378 if !m.Expunged && m.ParsedBuf != nil {
1379 var part message.Part
1380 err := json.Unmarshal(m.ParsedBuf, &part)
1381 xcheckf(ctx, err, "parsing part")
1383 dom, err := dns.ParseDomain(r.Domain)
1384 xcheckf(ctx, err, "parsing domain of recipient")
1388 checkAddrs := func(l []message.Address) {
1392 for _, a := range l {
1393 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1395 address = addressString(a, false)
1400 if part.Envelope != nil {
1401 env := part.Envelope
1408 matches = append(matches, address)
1412 xcheckf(ctx, err, "listing recipients")
1418// addressString returns an address into a string as it could be used in a message header.
1419func addressString(a message.Address, smtputf8 bool) string {
1421 dom, err := dns.ParseDomain(a.Host)
1423 if smtputf8 && dom.Unicode != "" {
1430 return "<" + a.User + "@" + host + ">"
1433 const atom = "!#$%&'*+-/=?^_`{|}~"
1435 for _, c := range a.Name {
1436 if c == '\t' || c == ' ' || c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsAny(string(c), atom) {
1439 // We need to quote.
1441 for _, c := range a.Name {
1442 if c == '\\' || c == '"' {
1450 return name + " <" + a.User + "@" + host + ">"
1453// MailboxSetSpecialUse sets the special use flags of a mailbox.
1454func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1455 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1456 acc := reqInfo.Account
1458 acc.WithWLock(func() {
1459 var changes []store.Change
1461 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1462 xmb := xmailboxID(ctx, tx, mb.ID)
1464 modseq, err := acc.NextModSeq(tx)
1465 xcheckf(ctx, err, "get next modseq")
1467 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1468 // we set, we clear it for the mailbox(es) that had it, if any.
1469 clearPrevious := func(clear bool, specialUse string) {
1473 var ombl []store.Mailbox
1474 q := bstore.QueryTx[store.Mailbox](tx)
1475 q.FilterNotEqual("ID", mb.ID)
1476 q.FilterEqual(specialUse, true)
1478 _, err := q.UpdateFields(map[string]any{specialUse: false, "ModSeq": modseq})
1479 xcheckf(ctx, err, "updating previous special-use mailboxes")
1481 for _, omb := range ombl {
1482 changes = append(changes, omb.ChangeSpecialUse())
1485 clearPrevious(mb.Archive, "Archive")
1486 clearPrevious(mb.Draft, "Draft")
1487 clearPrevious(mb.Junk, "Junk")
1488 clearPrevious(mb.Sent, "Sent")
1489 clearPrevious(mb.Trash, "Trash")
1491 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 || err == nil && m.Expunged {
1524 xcheckuserf(ctx, bstore.ErrAbsent, "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("Expunged", false)
1534 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1535 q.FilterNotEqual("ThreadCollapsed", collapse)
1536 q.FilterFn(func(tm store.Message) bool {
1537 for _, id := range tm.ThreadParentIDs {
1538 if _, ok := msgIDs[id]; ok {
1542 _, ok := msgIDs[tm.ID]
1546 q.SortAsc("ID") // Consistent order for testing.
1547 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1548 xcheckf(ctx, err, "updating collapse in database")
1550 for _, m := range updated {
1551 changes = append(changes, m.ChangeThread())
1554 store.BroadcastChanges(acc, changes)
1558// ThreadMute saves the ThreadMute field for the messages and their children.
1559// If messages are muted, they are also marked collapsed.
1560func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1561 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1562 acc := reqInfo.Account
1564 if len(messageIDs) == 0 {
1565 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1568 acc.WithWLock(func() {
1569 changes := make([]store.Change, 0, len(messageIDs))
1570 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1571 threadIDs := map[int64]struct{}{}
1572 msgIDs := map[int64]struct{}{}
1573 for _, id := range messageIDs {
1574 m := store.Message{ID: id}
1576 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1577 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1579 xcheckf(ctx, err, "get message")
1580 threadIDs[m.ThreadID] = struct{}{}
1581 msgIDs[id] = struct{}{}
1584 var updated []store.Message
1586 q := bstore.QueryTx[store.Message](tx)
1587 q.FilterEqual("Expunged", false)
1588 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1589 q.FilterFn(func(tm store.Message) bool {
1590 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1593 for _, id := range tm.ThreadParentIDs {
1594 if _, ok := msgIDs[id]; ok {
1598 _, ok := msgIDs[tm.ID]
1602 fields := map[string]any{"ThreadMuted": mute}
1604 fields["ThreadCollapsed"] = true
1606 _, err := q.UpdateFields(fields)
1607 xcheckf(ctx, err, "updating mute in database")
1609 for _, m := range updated {
1610 changes = append(changes, m.ChangeThread())
1613 store.BroadcastChanges(acc, changes)
1617// SecurityResult indicates whether a security feature is supported.
1618type SecurityResult string
1621 SecurityResultError SecurityResult = "error"
1622 SecurityResultNo SecurityResult = "no"
1623 SecurityResultYes SecurityResult = "yes"
1624 // Unknown whether supported. Finding out may only be (reasonably) possible when
1625 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1627 SecurityResultUnknown SecurityResult = "unknown"
1630// RecipientSecurity is a quick analysis of the security properties of delivery to
1631// the recipient (domain).
1632type RecipientSecurity struct {
1633 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1634 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1636 STARTTLS SecurityResult
1638 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1640 MTASTS SecurityResult
1642 // Whether MX lookup response was DNSSEC-signed.
1643 DNSSEC SecurityResult
1645 // Whether first delivery destination has DANE records.
1648 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1649 // Will be "unknown" if no delivery to the domain has been attempted yet.
1650 RequireTLS SecurityResult
1653// RecipientSecurity looks up security properties of the address in the
1654// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1655func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1656 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1659 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1660 return recipientSecurity(ctx, log, resolver, messageAddressee)
1663// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1664func logPanic(ctx context.Context) {
1669 log := pkglog.WithContext(ctx)
1670 log.Error("recover from panic", slog.Any("panic", x))
1672 metrics.PanicInc(metrics.Webmail)
1675// separate function for testing with mocked resolver.
1676func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1677 rs := RecipientSecurity{
1678 SecurityResultUnknown,
1679 SecurityResultUnknown,
1680 SecurityResultUnknown,
1681 SecurityResultUnknown,
1682 SecurityResultUnknown,
1685 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1686 msgAddr, err := parser.Parse(messageAddressee)
1688 return rs, fmt.Errorf("parsing addressee: %v", err)
1690 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1692 return rs, fmt.Errorf("parsing address: %v", err)
1695 var wg sync.WaitGroup
1703 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1704 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1705 rs.MTASTS = SecurityResultYes
1706 } else if err == nil {
1707 rs.MTASTS = SecurityResultNo
1709 rs.MTASTS = SecurityResultError
1719 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1721 rs.DNSSEC = SecurityResultError
1724 if origNextHopAuthentic && expandedNextHopAuthentic {
1725 rs.DNSSEC = SecurityResultYes
1727 rs.DNSSEC = SecurityResultNo
1730 if !origNextHopAuthentic {
1731 rs.DANE = SecurityResultNo
1735 // We're only looking at the first host to deliver to (typically first mx destination).
1736 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1737 return // Should not happen.
1741 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1742 // error result instead of no-DANE result.
1743 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1745 rs.DANE = SecurityResultError
1749 rs.DANE = SecurityResultNo
1753 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1755 rs.DANE = SecurityResultError
1757 } else if daneRequired {
1758 rs.DANE = SecurityResultYes
1760 rs.DANE = SecurityResultNo
1764 // STARTTLS and RequireTLS
1765 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1766 acc := reqInfo.Account
1768 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1769 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1770 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1772 if err == bstore.ErrAbsent {
1774 } else if err != nil {
1775 rs.STARTTLS = SecurityResultError
1776 rs.RequireTLS = SecurityResultError
1777 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1781 rs.STARTTLS = SecurityResultYes
1783 rs.STARTTLS = SecurityResultNo
1786 rs.RequireTLS = SecurityResultYes
1788 rs.RequireTLS = SecurityResultNo
1792 xcheckf(ctx, err, "lookup recipient domain")
1799// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1800func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1801 s, err := wordDecoder.DecodeHeader(text)
1802 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1806// SettingsSave saves settings, e.g. for composing.
1807func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1808 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1809 acc := reqInfo.Account
1812 err := acc.DB.Update(ctx, &settings)
1813 xcheckf(ctx, err, "save settings")
1816func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1817 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1818 acc := reqInfo.Account
1821 xdbread(ctx, acc, func(tx *bstore.Tx) {
1822 m := xmessageID(ctx, tx, msgID)
1823 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1824 mbDst := xmailboxID(ctx, tx, mbDstID)
1826 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1829 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1831 conf, _ := acc.Conf()
1832 dest := conf.Destinations[rcptTo] // May not be present.
1833 defaultMailbox := "Inbox"
1834 if dest.Mailbox != "" {
1835 defaultMailbox = dest.Mailbox
1838 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1839 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1843 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1844 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1845 xcheckf(ctx, err, "looking up previous response for source mailbox")
1849 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1850 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1855 // Parse message for List-Id header.
1856 state := msgState{acc: acc}
1858 pm, err := parsedMessage(log, &m, &state, true, false, false)
1859 xcheckf(ctx, err, "parsing message")
1861 // The suggested ruleset. Once all is checked, we'll return it.
1862 var nrs *config.Ruleset
1864 // If List-Id header is present, we'll treat it as a (mailing) list message.
1865 if l, ok := pm.Headers["List-Id"]; ok {
1867 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1870 var listIDDom dns.Domain
1871 listID, listIDDom = parseListID(l[0])
1873 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1877 // Check if we have a previous "No" answer for this list-id.
1878 no := store.RulesetNoListID{
1879 RcptToAddress: rcptTo,
1881 ToInbox: mbDst.Name == "Inbox",
1883 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1884 xcheckf(ctx, err, "looking up previous response for list-id")
1889 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1890 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1891 // are subscribed to, and take advantage of any reduced junk filtering.
1892 listIDDomStr := listIDDom.Name()
1894 doms := m.DKIMDomains
1895 if m.MailFromValidated {
1896 doms = append(doms, m.MailFromDomain)
1898 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1899 // of SPF verification of one host.
1900 sort.Slice(doms, func(i, j int) bool {
1901 return len(doms[i]) < len(doms[j])
1903 var listAllowDom string
1904 for _, dom := range doms {
1905 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1910 if listAllowDom == "" {
1914 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1915 nrs = &config.Ruleset{
1916 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1917 ListAllowDomain: listAllowDom,
1918 Mailbox: mbDst.Name,
1921 // Otherwise, try to make a rule based on message "From" address.
1922 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1925 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1927 no := store.RulesetNoMsgFrom{
1928 RcptToAddress: rcptTo,
1929 MsgFromAddress: msgFrom,
1930 ToInbox: mbDst.Name == "Inbox",
1932 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1933 xcheckf(ctx, err, "looking up previous response for message from address")
1938 nrs = &config.Ruleset{
1939 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1940 Mailbox: mbDst.Name,
1944 // Only suggest adding/removing rule if it isn't/is present.
1946 for _, rs := range dest.Rulesets {
1947 xrs := config.Ruleset{
1948 MsgFromRegexp: rs.MsgFromRegexp,
1949 HeadersRegexp: rs.HeadersRegexp,
1950 ListAllowDomain: rs.ListAllowDomain,
1951 Mailbox: nrs.Mailbox,
1953 if xrs.Equal(*nrs) {
1958 isRemove = mbDst.Name == defaultMailbox
1960 nrs.Mailbox = mbSrc.Name
1962 if isRemove && !have || !isRemove && have {
1966 // We'll be returning a suggested ruleset.
1967 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1973// Parse the list-id value (the value between <>) from a list-id header.
1974// Returns an empty string if it couldn't be parsed.
1975func parseListID(s string) (listID string, dom dns.Domain) {
1977 s = strings.TrimRight(s, " \t")
1978 if !strings.HasSuffix(s, ">") {
1979 return "", dns.Domain{}
1982 t := strings.Split(s, "<")
1984 return "", dns.Domain{}
1987 dom, err := dns.ParseDomain(s)
1994func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1995 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1997 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1998 dest, ok := acc.Destinations[rcptTo]
2000 // todo: we could find the catchall address and add the rule, or add the address explicitly.
2001 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")
2004 nd := map[string]config.Destination{}
2005 for addr, d := range acc.Destinations {
2008 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2010 acc.Destinations = nd
2012 xcheckf(ctx, err, "saving account with new ruleset")
2015func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2016 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2018 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2019 dest, ok := acc.Destinations[rcptTo]
2021 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2024 nd := map[string]config.Destination{}
2025 for addr, d := range acc.Destinations {
2028 var l []config.Ruleset
2030 for _, rs := range dest.Rulesets {
2031 if rs.Equal(ruleset) {
2038 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2042 acc.Destinations = nd
2044 xcheckf(ctx, err, "saving account with new ruleset")
2047func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2048 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2049 acc := reqInfo.Account
2053 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2055 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2057 xcheckf(ctx, err, "storing user response")
2060func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2061 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2062 acc := reqInfo.Account
2064 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2065 xcheckf(ctx, err, "storing user response")
2068func slicesAny[T any](l []T) []any {
2069 r := make([]any, len(l))
2070 for i, v := range l {
2076// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2077func (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) {