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 cryptorand.Read(data[:])
103 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
105 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
110// Login returns a session token for the credentials, or fails with error code
111// "user:badLogin". Call LoginPrep to get a loginToken.
112func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
113 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
116 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
117 if _, ok := err.(*sherpa.Error); ok {
120 xcheckf(ctx, err, "login")
124// Logout invalidates the session token.
125func (w Webmail) Logout(ctx context.Context) {
126 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
129 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.Account.Name, reqInfo.SessionToken)
130 xcheckf(ctx, err, "logout")
133// Version returns the version, goos and goarch.
134func (w Webmail) Version(ctx context.Context) (version, goos, goarch string) {
135 return moxvar.Version, runtime.GOOS, runtime.GOARCH
138// Token returns a single-use token to use for an SSE connection. A token can only
139// be used for a single SSE connection. Tokens are stored in memory for a maximum
140// of 1 minute, with at most 10 unused tokens (the most recently created) per
142func (Webmail) Token(ctx context.Context) string {
143 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
144 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
147// Requests sends a new request for an open SSE connection. Any currently active
148// request for the connection will be canceled, but this is done asynchrously, so
149// the SSE connection may still send results for the previous request. Callers
150// should take care to ignore such results. If req.Cancel is set, no new request is
152func (Webmail) Request(ctx context.Context, req Request) {
153 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
155 if !req.Cancel && req.Page.Count <= 0 {
156 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
159 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
161 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
166// MessageItem returns a MessageItem for a message.
167func (Webmail) MessageItem(ctx context.Context, msgID int64) (mi MessageItem) {
168 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
170 acc := reqInfo.Account
172 xdbread(ctx, acc, func(tx *bstore.Tx) {
173 m := xmessageID(ctx, tx, msgID)
175 state := msgState{acc: acc}
178 mi, err = messageItem(log, m, &state, nil)
179 xcheckf(ctx, err, "parsing message")
184// ParsedMessage returns enough to render the textual body of a message. It is
185// assumed the client already has other fields through MessageItem.
186func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
187 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
189 acc := reqInfo.Account
191 xdbread(ctx, acc, func(tx *bstore.Tx) {
192 m := xmessageID(ctx, tx, msgID)
194 state := msgState{acc: acc}
197 pm, err = parsedMessage(log, &m, &state, true, false, false)
198 xcheckf(ctx, err, "parsing message")
200 if len(pm.envelope.From) == 1 {
201 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
202 xcheckf(ctx, err, "looking up view mode for from address")
208// fromAddrViewMode returns the view mode for a from address.
209func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
210 settingsViewMode := func() (store.ViewMode, error) {
211 settings := store.Settings{ID: 1}
212 if err := tx.Get(&settings); err != nil {
213 return store.ModeText, err
215 if settings.ShowHTML {
216 return store.ModeHTML, nil
218 return store.ModeText, nil
221 lp, err := smtp.ParseLocalpart(from.User)
223 return settingsViewMode()
225 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
226 fas := store.FromAddressSettings{FromAddress: fromAddr}
228 if err == bstore.ErrAbsent {
229 return settingsViewMode()
230 } else if err != nil {
231 return store.ModeText, err
233 return fas.ViewMode, nil
236// FromAddressSettingsSave saves per-"From"-address settings.
237func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
238 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
239 acc := reqInfo.Account
241 if fas.FromAddress == "" {
242 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
245 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
246 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
247 err := tx.Update(&fas)
248 xcheckf(ctx, err, "updating settings for from address")
250 err := tx.Insert(&fas)
251 xcheckf(ctx, err, "inserting settings for from address")
256// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
257// of the message in storage. Used when opening a previously saved draft message
259// If no message is find, zero is returned, not an error.
260func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
261 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
262 acc := reqInfo.Account
264 messageID, _, _ = message.MessageIDCanonical(messageID)
266 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
269 xdbread(ctx, acc, func(tx *bstore.Tx) {
270 q := bstore.QueryTx[store.Message](tx)
271 q.FilterEqual("Expunged", false)
272 q.FilterNonzero(store.Message{MessageID: messageID})
274 if err == bstore.ErrAbsent {
277 xcheckf(ctx, err, "looking up message by message-id")
283// ComposeMessage is a message to be composed, for saving draft messages.
284type ComposeMessage struct {
289 ReplyTo string // If non-empty, Reply-To header to add to message.
292 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
293 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
296// MessageCompose composes a message and saves it to the mailbox. Used for
297// saving draft messages.
298func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
299 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
300 acc := reqInfo.Account
303 log.Debug("message compose")
305 // Prevent any accidental control characters, or attempts at getting bare \r or \n
307 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
308 for _, s := range l {
309 for _, c := range s {
311 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
317 fromAddr, err := parseAddress(m.From)
318 xcheckuserf(ctx, err, "parsing From address")
320 var replyTo *message.NameAddress
322 addr, err := parseAddress(m.ReplyTo)
323 xcheckuserf(ctx, err, "parsing Reply-To address")
327 var recipients []smtp.Address
329 var toAddrs []message.NameAddress
330 for _, s := range m.To {
331 addr, err := parseAddress(s)
332 xcheckuserf(ctx, err, "parsing To address")
333 toAddrs = append(toAddrs, addr)
334 recipients = append(recipients, addr.Address)
337 var ccAddrs []message.NameAddress
338 for _, s := range m.Cc {
339 addr, err := parseAddress(s)
340 xcheckuserf(ctx, err, "parsing Cc address")
341 ccAddrs = append(ccAddrs, addr)
342 recipients = append(recipients, addr.Address)
345 var bccAddrs []message.NameAddress
346 for _, s := range m.Bcc {
347 addr, err := parseAddress(s)
348 xcheckuserf(ctx, err, "parsing Bcc address")
349 bccAddrs = append(bccAddrs, addr)
350 recipients = append(recipients, addr.Address)
353 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
355 for _, a := range recipients {
356 if a.Localpart.IsInternational() {
361 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
362 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
365 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
369 // Create file to compose message into.
370 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
371 xcheckf(ctx, err, "creating temporary file for compose message")
372 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
374 // If writing to the message file fails, we abort immediately.
375 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
381 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
382 xcheckuserf(ctx, err, "making message")
383 } else if ok && errors.Is(err, message.ErrCompose) {
384 xcheckf(ctx, err, "making message")
389 // Outer message headers.
390 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
392 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
394 xc.HeaderAddrs("To", toAddrs)
395 xc.HeaderAddrs("Cc", ccAddrs)
396 xc.HeaderAddrs("Bcc", bccAddrs)
398 xc.Subject(m.Subject)
401 // Add In-Reply-To and References headers.
402 if m.ResponseMessageID > 0 {
403 xdbread(ctx, acc, func(tx *bstore.Tx) {
404 rm := xmessageID(ctx, tx, m.ResponseMessageID)
405 msgr := acc.MessageReader(rm)
408 log.Check(err, "closing message reader")
410 rp, err := rm.LoadPart(msgr)
411 xcheckf(ctx, err, "load parsed message")
412 h, err := rp.Header()
413 xcheckf(ctx, err, "parsing header")
415 if rp.Envelope == nil {
419 if rp.Envelope.MessageID != "" {
420 xc.Header("In-Reply-To", rp.Envelope.MessageID)
422 refs := h.Values("References")
423 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
424 refs = []string{rp.Envelope.InReplyTo}
426 if rp.Envelope.MessageID != "" {
427 refs = append(refs, rp.Envelope.MessageID)
430 xc.Header("References", strings.Join(refs, "\r\n\t"))
434 xc.Header("MIME-Version", "1.0")
435 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
436 xc.Header("Content-Type", ct)
437 xc.Header("Content-Transfer-Encoding", cte)
439 xc.Write([]byte(textBody))
444 // Remove previous draft message, append message to destination mailbox.
445 acc.WithWLock(func() {
446 var changes []store.Change
450 for _, id := range newIDs {
451 p := acc.MessagePath(id)
453 log.Check(err, "removing added message aftr error", slog.String("path", p))
457 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
458 var modseq store.ModSeq // Only set if needed.
460 if m.DraftMessageID > 0 {
461 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
462 changes = append(changes, nchanges...)
465 mb, err := store.MailboxID(tx, mailboxID)
466 xcheckf(ctx, err, "looking up mailbox")
469 modseq, err = acc.NextModSeq(tx)
470 xcheckf(ctx, err, "next modseq")
477 MailboxOrigID: mb.ID,
478 Flags: store.Flags{Notjunk: true},
482 err = acc.MessageAdd(log, tx, &mb, &nm, dataFile, store.AddOpts{})
483 if err != nil && errors.Is(err, store.ErrOverQuota) {
484 xcheckuserf(ctx, err, "checking quota")
486 xcheckf(ctx, err, "storing message in mailbox")
487 newIDs = append(newIDs, nm.ID)
490 xcheckf(ctx, err, "updating sent mailbox for counts")
492 changes = append(changes, nm.ChangeAddUID(mb), mb.ChangeCounts())
496 store.BroadcastChanges(acc, changes)
502// Attachment is a MIME part is an existing message that is not intended as
503// viewable text or HTML part.
504type Attachment struct {
505 Path []int // Indices into top-level message.Part.Parts.
507 // File name based on "name" attribute of "Content-Type", or the "filename"
508 // attribute of "Content-Disposition".
514// SubmitMessage is an email message to be sent to one or more recipients.
515// Addresses are formatted as just email address, or with a name like "name
517type SubmitMessage struct {
522 ReplyTo string // If non-empty, Reply-To header to add to message.
526 ForwardAttachments ForwardAttachments
528 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
529 UserAgent string // User-Agent header added if not empty.
530 RequireTLS *bool // For "Require TLS" extension during delivery.
531 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
532 ArchiveThread bool // If set, thread is archived after sending message.
533 ArchiveReferenceMailboxID int64 // If ArchiveThread is set, thread messages from this mailbox ID are moved to the archive mailbox ID. E.g. of Inbox.
534 DraftMessageID int64 // If set, draft message that will be removed after sending.
537// ForwardAttachments references attachments by a list of message.Part paths.
538type ForwardAttachments struct {
539 MessageID int64 // Only relevant if MessageID is not 0.
540 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
543// File is a new attachment (not from an existing message that is being
544// forwarded) to send with a SubmitMessage.
547 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
550// parseAddress expects either a plain email address like "user@domain", or a
551// single address as used in a message header, like "name <user@domain>".
552func parseAddress(msghdr string) (message.NameAddress, error) {
554 parser := mail.AddressParser{WordDecoder: &wordDecoder}
555 a, err := parser.Parse(msghdr)
557 return message.NameAddress{}, err
560 path, err := smtp.ParseNetMailAddress(a.Address)
562 return message.NameAddress{}, err
564 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
567func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
569 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
571 mb, err := store.MailboxID(tx, mailboxID)
572 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
573 xcheckuserf(ctx, err, "getting mailbox")
575 xcheckf(ctx, err, "getting mailbox")
579// xmessageID returns a non-expunged message or panics with a sherpa error.
580func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
582 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
584 m := store.Message{ID: messageID}
586 if err == bstore.ErrAbsent {
587 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
588 } else if err == nil && m.Expunged {
589 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
591 xcheckf(ctx, err, "getting message")
595func xrandomID(ctx context.Context, n int) string {
596 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
599func xrandom(ctx context.Context, n int) []byte {
600 buf := make([]byte, n)
605// MessageSubmit sends a message by submitting it the outgoing email queue. The
606// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
607// Bcc message header.
609// If a Sent mailbox is configured, messages are added to it after submitting
610// to the delivery queue. If Bcc addresses were present, a header is prepended
611// to the message stored in the Sent mailbox.
612func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
613 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
614 acc := reqInfo.Account
617 log.Debug("message submit")
619 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
621 // 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.
623 // Prevent any accidental control characters, or attempts at getting bare \r or \n
625 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
626 for _, s := range l {
627 for _, c := range s {
629 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
635 fromAddr, err := parseAddress(m.From)
636 xcheckuserf(ctx, err, "parsing From address")
638 var replyTo *message.NameAddress
640 a, err := parseAddress(m.ReplyTo)
641 xcheckuserf(ctx, err, "parsing Reply-To address")
645 var recipients []smtp.Address
647 var toAddrs []message.NameAddress
648 for _, s := range m.To {
649 addr, err := parseAddress(s)
650 xcheckuserf(ctx, err, "parsing To address")
651 toAddrs = append(toAddrs, addr)
652 recipients = append(recipients, addr.Address)
655 var ccAddrs []message.NameAddress
656 for _, s := range m.Cc {
657 addr, err := parseAddress(s)
658 xcheckuserf(ctx, err, "parsing Cc address")
659 ccAddrs = append(ccAddrs, addr)
660 recipients = append(recipients, addr.Address)
663 var bccAddrs []message.NameAddress
664 for _, s := range m.Bcc {
665 addr, err := parseAddress(s)
666 xcheckuserf(ctx, err, "parsing Bcc address")
667 bccAddrs = append(bccAddrs, addr)
668 recipients = append(recipients, addr.Address)
671 // Check if from address is allowed for account.
672 if ok, disabled := mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address); disabled {
673 metricSubmission.WithLabelValues("domaindisabled").Inc()
674 xcheckuserf(ctx, mox.ErrDomainDisabled, `looking up "from" address for account`)
676 metricSubmission.WithLabelValues("badfrom").Inc()
677 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
680 if len(recipients) == 0 {
681 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
684 // Check outgoing message rate limit.
685 xdbread(ctx, acc, func(tx *bstore.Tx) {
686 rcpts := make([]smtp.Path, len(recipients))
687 for i, r := range recipients {
688 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
690 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
692 metricSubmission.WithLabelValues("messagelimiterror").Inc()
693 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
694 } else if rcptlimit >= 0 {
695 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
696 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
698 xcheckf(ctx, err, "checking send limit")
701 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
703 for _, a := range recipients {
704 if a.Localpart.IsInternational() {
709 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
710 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
713 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
717 // Create file to compose message into.
718 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
719 xcheckf(ctx, err, "creating temporary file for message")
720 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
722 // If writing to the message file fails, we abort immediately.
723 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
729 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
730 xcheckuserf(ctx, err, "making message")
731 } else if ok && errors.Is(err, message.ErrCompose) {
732 xcheckf(ctx, err, "making message")
737 // 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
739 // Each queued message gets a Received header.
740 // We don't have access to the local IP for adding.
741 // We cannot use VIA, because there is no registered method. We would like to use
742 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
743 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
744 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
745 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
746 recvHdrFor := func(rcptTo string) string {
747 recvHdr := &message.HeaderWriter{}
748 // For additional Received-header clauses, see:
749 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
750 // Note: we don't have "via" or "with", there is no registered for webmail.
751 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
752 if reqInfo.Request.TLS != nil {
753 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
755 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
756 return recvHdr.String()
759 // Outer message headers.
760 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
762 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
764 xc.HeaderAddrs("To", toAddrs)
765 xc.HeaderAddrs("Cc", ccAddrs)
766 // We prepend Bcc headers to the message when adding to the Sent mailbox.
768 xc.Subject(m.Subject)
771 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
772 xc.Header("Message-Id", messageID)
773 xc.Header("Date", time.Now().Format(message.RFC5322Z))
774 // Add In-Reply-To and References headers.
775 if m.ResponseMessageID > 0 {
776 xdbread(ctx, acc, func(tx *bstore.Tx) {
777 rm := xmessageID(ctx, tx, m.ResponseMessageID)
778 msgr := acc.MessageReader(rm)
781 log.Check(err, "closing message reader")
783 rp, err := rm.LoadPart(msgr)
784 xcheckf(ctx, err, "load parsed message")
785 h, err := rp.Header()
786 xcheckf(ctx, err, "parsing header")
788 if rp.Envelope == nil {
792 if rp.Envelope.MessageID != "" {
793 xc.Header("In-Reply-To", rp.Envelope.MessageID)
795 refs := h.Values("References")
796 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
797 refs = []string{rp.Envelope.InReplyTo}
799 if rp.Envelope.MessageID != "" {
800 refs = append(refs, rp.Envelope.MessageID)
803 xc.Header("References", strings.Join(refs, "\r\n\t"))
807 if m.UserAgent != "" {
808 xc.Header("User-Agent", m.UserAgent)
810 if m.RequireTLS != nil && !*m.RequireTLS {
811 xc.Header("TLS-Required", "No")
813 xc.Header("MIME-Version", "1.0")
815 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
816 mp := multipart.NewWriter(xc)
817 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
820 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
821 textHdr := textproto.MIMEHeader{}
822 textHdr.Set("Content-Type", ct)
823 textHdr.Set("Content-Transfer-Encoding", cte)
825 textp, err := mp.CreatePart(textHdr)
826 xcheckf(ctx, err, "adding text part to message")
827 _, err = textp.Write(textBody)
828 xcheckf(ctx, err, "writing text part")
830 xaddPart := func(ct, filename string) io.Writer {
831 ahdr := textproto.MIMEHeader{}
832 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
834 ahdr.Set("Content-Type", ct)
835 ahdr.Set("Content-Transfer-Encoding", "base64")
836 ahdr.Set("Content-Disposition", cd)
837 ap, err := mp.CreatePart(ahdr)
838 xcheckf(ctx, err, "adding attachment part to message")
842 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
843 ap := xaddPart(ct, filename)
845 for len(base64Data) > 0 {
848 line, base64Data = base64Data[:n], base64Data[n:]
849 _, err := ap.Write(line)
850 xcheckf(ctx, err, "writing attachment")
851 _, err = ap.Write([]byte("\r\n"))
852 xcheckf(ctx, err, "writing attachment")
856 xaddAttachment := func(ct, filename string, r io.Reader) {
857 ap := xaddPart(ct, filename)
858 wc := moxio.Base64Writer(ap)
859 _, err := io.Copy(wc, r)
860 xcheckf(ctx, err, "adding attachment")
862 xcheckf(ctx, err, "flushing attachment")
865 for _, a := range m.Attachments {
867 if !strings.HasPrefix(s, "data:") {
868 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
871 t := strings.SplitN(s, ",", 2)
873 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
875 if !strings.HasSuffix(t[0], "base64") {
876 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
878 ct := strings.TrimSuffix(t[0], "base64")
879 ct = strings.TrimSuffix(ct, ";")
881 ct = "application/octet-stream"
883 filename := a.Filename
885 filename = "unnamed.bin"
887 params := map[string]string{"name": filename}
888 ct = mime.FormatMediaType(ct, params)
890 // Ensure base64 is valid, then we'll write the original string.
891 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
892 xcheckuserf(ctx, err, "parsing attachment as base64")
894 xaddAttachmentBase64(ct, filename, []byte(t[1]))
897 if len(m.ForwardAttachments.Paths) > 0 {
898 acc.WithRLock(func() {
899 xdbread(ctx, acc, func(tx *bstore.Tx) {
900 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
901 msgr := acc.MessageReader(fm)
904 log.Check(err, "closing message reader")
907 fp, err := fm.LoadPart(msgr)
908 xcheckf(ctx, err, "load parsed message")
910 for _, path := range m.ForwardAttachments.Paths {
912 for _, xp := range path {
913 if xp < 0 || xp >= len(ap.Parts) {
914 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
919 _, filename, err := ap.DispositionFilename()
920 if err != nil && errors.Is(err, message.ErrParamEncoding) {
921 log.Debugx("parsing disposition/filename", err)
923 xcheckf(ctx, err, "reading disposition")
926 filename = "unnamed.bin"
928 params := map[string]string{"name": filename}
929 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
930 params["charset"] = pcharset
932 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
933 ct = mime.FormatMediaType(ct, params)
934 xaddAttachment(ct, filename, ap.Reader())
941 xcheckf(ctx, err, "writing mime multipart")
943 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
944 xc.Header("Content-Type", ct)
945 xc.Header("Content-Transfer-Encoding", cte)
947 xc.Write([]byte(textBody))
952 // Add DKIM-Signature headers.
954 fd := fromAddr.Address.Domain
955 confDom, _ := mox.Conf.Domain(fd)
956 if confDom.Disabled {
957 xcheckuserf(ctx, mox.ErrDomainDisabled, "checking domain")
959 selectors := mox.DKIMSelectors(confDom.DKIM)
960 if len(selectors) > 0 {
961 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
963 metricServerErrors.WithLabelValues("dkimsign").Inc()
965 xcheckf(ctx, err, "sign dkim")
967 msgPrefix = dkimHeaders
970 accConf, _ := acc.Conf()
971 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
972 xcheckf(ctx, err, "parsing login address")
973 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
974 fromPath := fromAddr.Address.Path()
975 var localpartBase string
977 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0]
979 qml := make([]queue.Msg, len(recipients))
981 for i, rcpt := range recipients {
985 fromID = xrandomID(ctx, 16)
986 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromID)
989 // Don't use per-recipient unique message prefix when multiple recipients are
990 // present, or the queue cannot deliver it in a single smtp transaction.
992 if len(recipients) == 1 {
993 recvRcpt = rcpt.Pack(smtputf8)
995 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
996 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
998 Localpart: rcpt.Localpart,
999 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
1001 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
1002 if m.FutureRelease != nil {
1003 ival := time.Until(*m.FutureRelease)
1005 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
1006 } else if ival > queue.FutureReleaseIntervalMax {
1007 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
1009 qm.NextAttempt = *m.FutureRelease
1010 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
1011 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
1014 // no qm.Extra from webmail
1017 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
1019 metricSubmission.WithLabelValues("queueerror").Inc()
1021 xcheckf(ctx, err, "adding messages to the delivery queue")
1022 metricSubmission.WithLabelValues("ok").Inc()
1024 var modseq store.ModSeq // Only set if needed.
1026 // We have committed to sending the message. We want to follow through
1027 // with appending to Sent and removing the draft message.
1028 ctx = context.WithoutCancel(ctx)
1030 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1031 // remove any draft message.
1032 acc.WithWLock(func() {
1033 var changes []store.Change
1037 if x := recover(); x != nil {
1039 metricServerErrors.WithLabelValues("submit").Inc()
1047 for _, id := range newIDs {
1048 p := acc.MessagePath(id)
1050 log.Check(err, "removing delivered message on error", slog.String("path", p))
1054 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1055 if m.DraftMessageID > 0 {
1056 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
1057 changes = append(changes, nchanges...)
1060 if m.ResponseMessageID > 0 {
1061 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1068 if !rm.Junk && !rm.Notjunk {
1071 if rm.Flags != oflags {
1073 modseq, err = acc.NextModSeq(tx)
1074 xcheckf(ctx, err, "next modseq")
1077 err := tx.Update(&rm)
1078 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1080 // Update modseq of mailbox of replied/forwarded message.
1081 rmb, err := store.MailboxID(tx, rm.MailboxID)
1082 xcheckf(ctx, err, "get mailbox of replied/forwarded message for modseq update")
1084 err = tx.Update(&rmb)
1085 xcheckf(ctx, err, "update modseq of mailbox of replied/forwarded message")
1087 changes = append(changes, rm.ChangeFlags(oflags, rmb))
1089 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm})
1090 xcheckf(ctx, err, "retraining messages after reply/forward")
1093 // Move messages from this thread still in this mailbox to the designated Archive
1095 if m.ArchiveThread {
1096 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Archive", true).Get()
1097 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1098 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1100 xcheckf(ctx, err, "looking up designated archive mailbox")
1103 q := bstore.QueryTx[store.Message](tx)
1104 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1105 q.FilterEqual("Expunged", false)
1106 err = q.IDs(&msgIDs)
1107 xcheckf(ctx, err, "listing messages in thread to archive")
1108 if len(msgIDs) > 0 {
1109 ids, nchanges := xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, &modseq, true)
1110 newIDs = append(newIDs, ids...)
1111 changes = append(changes, nchanges...)
1116 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1117 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1118 // There is no mailbox designated as Sent mailbox, so we're done.
1121 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1124 modseq, err = acc.NextModSeq(tx)
1125 xcheckf(ctx, err, "next modseq")
1128 // If there were bcc headers, prepend those to the stored message only, before the
1129 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1130 // message won't validate with DKIM anymore, which is fine.
1131 if len(bccAddrs) > 0 {
1132 var sb strings.Builder
1133 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1134 xbcc.HeaderAddrs("Bcc", bccAddrs)
1136 msgPrefix = sb.String() + msgPrefix
1139 sentm := store.Message{
1142 MailboxID: sentmb.ID,
1143 MailboxOrigID: sentmb.ID,
1144 Flags: store.Flags{Notjunk: true, Seen: true},
1145 Size: int64(len(msgPrefix)) + xc.Size,
1146 MsgPrefix: []byte(msgPrefix),
1149 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1150 if err != nil && errors.Is(err, store.ErrOverQuota) {
1151 xcheckuserf(ctx, err, "checking quota")
1152 } else if err != nil {
1153 metricSubmission.WithLabelValues("storesenterror").Inc()
1156 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1157 newIDs = append(newIDs, sentm.ID)
1159 err = tx.Update(&sentmb)
1160 xcheckf(ctx, err, "updating sent mailbox for counts")
1162 changes = append(changes, sentm.ChangeAddUID(sentmb), sentmb.ChangeCounts())
1166 store.BroadcastChanges(acc, changes)
1170// MessageMove moves messages to another mailbox. If the message is already in
1171// the mailbox an error is returned.
1172func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64, markSeen bool) {
1173 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1174 acc := reqInfo.Account
1177 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID, markSeen)
1180var xops = webops.XOps{
1183 Checkuserf: xcheckuserf,
1186// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1187func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1188 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1189 acc := reqInfo.Account
1192 if len(messageIDs) == 0 {
1196 xops.MessageDelete(ctx, log, acc, messageIDs)
1199// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1200// flags should be lower-case, but will be converted and verified.
1201func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1202 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1203 acc := reqInfo.Account
1206 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1209// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1210func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1211 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1212 acc := reqInfo.Account
1215 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1218// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1219// not automatically included, they must explicitly be included in the list of IDs.
1220func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1221 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1222 acc := reqInfo.Account
1225 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1228// MailboxCreate creates a new mailbox.
1229func (Webmail) MailboxCreate(ctx context.Context, name string) {
1230 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1231 acc := reqInfo.Account
1234 name, _, err = config.CheckMailboxName(name, false)
1235 xcheckuserf(ctx, err, "checking mailbox name")
1237 acc.WithWLock(func() {
1238 var changes []store.Change
1239 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1242 _, changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1244 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1246 xcheckf(ctx, err, "creating mailbox")
1249 store.BroadcastChanges(acc, changes)
1253// MailboxDelete deletes a mailbox and all its messages and annotations.
1254func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1255 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1256 acc := reqInfo.Account
1259 acc.WithWLock(func() {
1260 var changes []store.Change
1262 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1263 mb := xmailboxID(ctx, tx, mailboxID)
1264 if mb.Name == "Inbox" {
1265 // Inbox is special in IMAP and cannot be removed.
1266 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1269 var hasChildren bool
1271 changes, hasChildren, err = acc.MailboxDelete(ctx, log, tx, &mb)
1273 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1275 xcheckf(ctx, err, "deleting mailbox")
1278 store.BroadcastChanges(acc, changes)
1282// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1283// its child mailboxes.
1284func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1285 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1286 acc := reqInfo.Account
1289 acc.WithWLock(func() {
1290 var changes []store.Change
1292 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1293 mb := xmailboxID(ctx, tx, mailboxID)
1295 qm := bstore.QueryTx[store.Message](tx)
1296 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1297 qm.FilterEqual("Expunged", false)
1300 xcheckf(ctx, err, "listing messages to remove")
1303 xcheckuserf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1306 modseq, err := acc.NextModSeq(tx)
1307 xcheckf(ctx, err, "next modseq")
1309 chrem, chmbcounts, err := acc.MessageRemove(log, tx, modseq, &mb, store.RemoveOpts{}, l...)
1310 xcheckf(ctx, err, "expunge messages")
1311 changes = append(changes, chrem, chmbcounts)
1313 err = tx.Update(&mb)
1314 xcheckf(ctx, err, "updating mailbox for counts")
1317 store.BroadcastChanges(acc, changes)
1321// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1322// ID and its messages are unchanged.
1323func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1324 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1325 acc := reqInfo.Account
1327 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1328 // standard. We can just say no.
1330 newName, _, err = config.CheckMailboxName(newName, false)
1331 xcheckuserf(ctx, err, "checking new mailbox name")
1333 acc.WithWLock(func() {
1334 var changes []store.Change
1336 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1337 mbsrc := xmailboxID(ctx, tx, mailboxID)
1339 var isInbox, alreadyExists bool
1340 var modseq store.ModSeq
1341 changes, isInbox, alreadyExists, err = acc.MailboxRename(tx, &mbsrc, newName, &modseq)
1342 if isInbox || alreadyExists {
1343 xcheckuserf(ctx, err, "renaming mailbox")
1345 xcheckf(ctx, err, "renaming mailbox")
1348 store.BroadcastChanges(acc, changes)
1352// CompleteRecipient returns autocomplete matches for a recipient, returning the
1353// matches, most recently used first, and whether this is the full list and further
1354// requests for longer prefixes aren't necessary.
1355func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1356 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1357 acc := reqInfo.Account
1359 search = strings.ToLower(search)
1361 var matches []string
1363 acc.WithRLock(func() {
1364 xdbread(ctx, acc, func(tx *bstore.Tx) {
1369 seen := map[key]bool{}
1371 q := bstore.QueryTx[store.Recipient](tx)
1373 err := q.ForEach(func(r store.Recipient) error {
1374 k := key{r.Localpart, r.Domain}
1378 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1379 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1380 if !strings.Contains(strings.ToLower(address), search) {
1383 if len(matches) >= 20 {
1385 return bstore.StopForEach
1388 // Look in the message that was sent for a name along with the address.
1389 m := store.Message{ID: r.MessageID}
1391 xcheckf(ctx, err, "get sent message")
1392 if !m.Expunged && m.ParsedBuf != nil {
1393 var part message.Part
1394 err := json.Unmarshal(m.ParsedBuf, &part)
1395 xcheckf(ctx, err, "parsing part")
1397 dom, err := dns.ParseDomain(r.Domain)
1398 xcheckf(ctx, err, "parsing domain of recipient")
1402 checkAddrs := func(l []message.Address) {
1406 for _, a := range l {
1407 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1409 address = addressString(a, false)
1414 if part.Envelope != nil {
1415 env := part.Envelope
1422 matches = append(matches, address)
1426 xcheckf(ctx, err, "listing recipients")
1432// addressString returns an address into a string as it could be used in a message header.
1433func addressString(a message.Address, smtputf8 bool) string {
1435 dom, err := dns.ParseDomain(a.Host)
1437 if smtputf8 && dom.Unicode != "" {
1444 return "<" + a.User + "@" + host + ">"
1447 const atom = "!#$%&'*+-/=?^_`{|}~"
1449 for _, c := range a.Name {
1450 if c == '\t' || c == ' ' || c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsAny(string(c), atom) {
1453 // We need to quote.
1454 var q strings.Builder
1456 for _, c := range a.Name {
1457 if c == '\\' || c == '"' {
1460 q.WriteString(string(c))
1465 return name + " <" + a.User + "@" + host + ">"
1468// MailboxSetSpecialUse sets the special use flags of a mailbox.
1469func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1470 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1471 acc := reqInfo.Account
1473 acc.WithWLock(func() {
1474 var changes []store.Change
1476 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1477 xmb := xmailboxID(ctx, tx, mb.ID)
1479 modseq, err := acc.NextModSeq(tx)
1480 xcheckf(ctx, err, "get next modseq")
1482 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1483 // we set, we clear it for the mailbox(es) that had it, if any.
1484 clearPrevious := func(clear bool, specialUse string) {
1488 var ombl []store.Mailbox
1489 q := bstore.QueryTx[store.Mailbox](tx)
1490 q.FilterNotEqual("ID", mb.ID)
1491 q.FilterEqual(specialUse, true)
1493 _, err := q.UpdateFields(map[string]any{specialUse: false, "ModSeq": modseq})
1494 xcheckf(ctx, err, "updating previous special-use mailboxes")
1496 for _, omb := range ombl {
1497 changes = append(changes, omb.ChangeSpecialUse())
1500 clearPrevious(mb.Archive, "Archive")
1501 clearPrevious(mb.Draft, "Draft")
1502 clearPrevious(mb.Junk, "Junk")
1503 clearPrevious(mb.Sent, "Sent")
1504 clearPrevious(mb.Trash, "Trash")
1506 xmb.SpecialUse = mb.SpecialUse
1508 err = tx.Update(&xmb)
1509 xcheckf(ctx, err, "updating special-use flags for mailbox")
1510 changes = append(changes, xmb.ChangeSpecialUse())
1513 store.BroadcastChanges(acc, changes)
1517// ThreadCollapse saves the ThreadCollapse field for the messages and its
1518// children. The messageIDs are typically thread roots. But not all roots
1519// (without parent) of a thread need to have the same collapsed state.
1520func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1521 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1522 acc := reqInfo.Account
1524 if len(messageIDs) == 0 {
1525 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1528 acc.WithWLock(func() {
1529 changes := make([]store.Change, 0, len(messageIDs))
1530 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1531 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1532 // (child) messages. Further refined in FilterFn.
1533 threadIDs := map[int64]struct{}{}
1534 msgIDs := map[int64]struct{}{}
1535 for _, id := range messageIDs {
1536 m := store.Message{ID: id}
1538 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1539 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1541 xcheckf(ctx, err, "get message")
1542 threadIDs[m.ThreadID] = struct{}{}
1543 msgIDs[id] = struct{}{}
1546 var updated []store.Message
1547 q := bstore.QueryTx[store.Message](tx)
1548 q.FilterEqual("Expunged", false)
1549 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1550 q.FilterNotEqual("ThreadCollapsed", collapse)
1551 q.FilterFn(func(tm store.Message) bool {
1552 for _, id := range tm.ThreadParentIDs {
1553 if _, ok := msgIDs[id]; ok {
1557 _, ok := msgIDs[tm.ID]
1561 q.SortAsc("ID") // Consistent order for testing.
1562 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1563 xcheckf(ctx, err, "updating collapse in database")
1565 for _, m := range updated {
1566 changes = append(changes, m.ChangeThread())
1569 store.BroadcastChanges(acc, changes)
1573// ThreadMute saves the ThreadMute field for the messages and their children.
1574// If messages are muted, they are also marked collapsed.
1575func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1576 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1577 acc := reqInfo.Account
1579 if len(messageIDs) == 0 {
1580 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1583 acc.WithWLock(func() {
1584 changes := make([]store.Change, 0, len(messageIDs))
1585 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1586 threadIDs := map[int64]struct{}{}
1587 msgIDs := map[int64]struct{}{}
1588 for _, id := range messageIDs {
1589 m := store.Message{ID: id}
1591 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1592 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1594 xcheckf(ctx, err, "get message")
1595 threadIDs[m.ThreadID] = struct{}{}
1596 msgIDs[id] = struct{}{}
1599 var updated []store.Message
1601 q := bstore.QueryTx[store.Message](tx)
1602 q.FilterEqual("Expunged", false)
1603 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1604 q.FilterFn(func(tm store.Message) bool {
1605 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1608 for _, id := range tm.ThreadParentIDs {
1609 if _, ok := msgIDs[id]; ok {
1613 _, ok := msgIDs[tm.ID]
1617 fields := map[string]any{"ThreadMuted": mute}
1619 fields["ThreadCollapsed"] = true
1621 _, err := q.UpdateFields(fields)
1622 xcheckf(ctx, err, "updating mute in database")
1624 for _, m := range updated {
1625 changes = append(changes, m.ChangeThread())
1628 store.BroadcastChanges(acc, changes)
1632// SecurityResult indicates whether a security feature is supported.
1633type SecurityResult string
1636 SecurityResultError SecurityResult = "error"
1637 SecurityResultNo SecurityResult = "no"
1638 SecurityResultYes SecurityResult = "yes"
1639 // Unknown whether supported. Finding out may only be (reasonably) possible when
1640 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1642 SecurityResultUnknown SecurityResult = "unknown"
1645// RecipientSecurity is a quick analysis of the security properties of delivery to
1646// the recipient (domain).
1647type RecipientSecurity struct {
1648 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1649 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1651 STARTTLS SecurityResult
1653 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1655 MTASTS SecurityResult
1657 // Whether MX lookup response was DNSSEC-signed.
1658 DNSSEC SecurityResult
1660 // Whether first delivery destination has DANE records.
1663 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1664 // Will be "unknown" if no delivery to the domain has been attempted yet.
1665 RequireTLS SecurityResult
1668// RecipientSecurity looks up security properties of the address in the
1669// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1670func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1671 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1674 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1675 return recipientSecurity(ctx, log, resolver, messageAddressee)
1678// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1679func logPanic(ctx context.Context) {
1684 log := pkglog.WithContext(ctx)
1685 log.Error("recover from panic", slog.Any("panic", x))
1687 metrics.PanicInc(metrics.Webmail)
1690// separate function for testing with mocked resolver.
1691func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1692 rs := RecipientSecurity{
1693 SecurityResultUnknown,
1694 SecurityResultUnknown,
1695 SecurityResultUnknown,
1696 SecurityResultUnknown,
1697 SecurityResultUnknown,
1700 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1701 msgAddr, err := parser.Parse(messageAddressee)
1703 return rs, fmt.Errorf("parsing addressee: %v", err)
1705 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1707 return rs, fmt.Errorf("parsing address: %v", err)
1710 var wg sync.WaitGroup
1718 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1719 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1720 rs.MTASTS = SecurityResultYes
1721 } else if err == nil {
1722 rs.MTASTS = SecurityResultNo
1724 rs.MTASTS = SecurityResultError
1734 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hostPrefs, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1736 rs.DNSSEC = SecurityResultError
1739 if origNextHopAuthentic && expandedNextHopAuthentic {
1740 rs.DNSSEC = SecurityResultYes
1742 rs.DNSSEC = SecurityResultNo
1745 if !origNextHopAuthentic {
1746 rs.DANE = SecurityResultNo
1750 // We're only looking at the first host to deliver to (typically first mx destination).
1751 if len(hostPrefs) == 0 || hostPrefs[0].Host.Domain.IsZero() {
1752 return // Should not happen.
1754 host := hostPrefs[0].Host
1756 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1757 // error result instead of no-DANE result.
1758 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1760 rs.DANE = SecurityResultError
1764 rs.DANE = SecurityResultNo
1768 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1770 rs.DANE = SecurityResultError
1772 } else if daneRequired {
1773 rs.DANE = SecurityResultYes
1775 rs.DANE = SecurityResultNo
1779 // STARTTLS and RequireTLS
1780 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1781 acc := reqInfo.Account
1783 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1784 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1785 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1787 if err == bstore.ErrAbsent {
1789 } else if err != nil {
1790 rs.STARTTLS = SecurityResultError
1791 rs.RequireTLS = SecurityResultError
1792 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1796 rs.STARTTLS = SecurityResultYes
1798 rs.STARTTLS = SecurityResultNo
1801 rs.RequireTLS = SecurityResultYes
1803 rs.RequireTLS = SecurityResultNo
1807 xcheckf(ctx, err, "lookup recipient domain")
1814// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1815func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1816 s, err := wordDecoder.DecodeHeader(text)
1817 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1821// SettingsSave saves settings, e.g. for composing.
1822func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1823 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1824 acc := reqInfo.Account
1827 err := acc.DB.Update(ctx, &settings)
1828 xcheckf(ctx, err, "save settings")
1831func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1832 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1833 acc := reqInfo.Account
1836 xdbread(ctx, acc, func(tx *bstore.Tx) {
1837 m := xmessageID(ctx, tx, msgID)
1838 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1839 mbDst := xmailboxID(ctx, tx, mbDstID)
1841 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1844 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1846 conf, _ := acc.Conf()
1847 dest := conf.Destinations[rcptTo] // May not be present.
1848 defaultMailbox := "Inbox"
1849 if dest.Mailbox != "" {
1850 defaultMailbox = dest.Mailbox
1853 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1854 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1858 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1859 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1860 xcheckf(ctx, err, "looking up previous response for source mailbox")
1864 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1865 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1870 // Parse message for List-Id header.
1871 state := msgState{acc: acc}
1873 pm, err := parsedMessage(log, &m, &state, true, false, false)
1874 xcheckf(ctx, err, "parsing message")
1876 // The suggested ruleset. Once all is checked, we'll return it.
1877 var nrs *config.Ruleset
1879 // If List-Id header is present, we'll treat it as a (mailing) list message.
1880 if l, ok := pm.Headers["List-Id"]; ok {
1882 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1885 var listIDDom dns.Domain
1886 listID, listIDDom = parseListID(l[0])
1888 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1892 // Check if we have a previous "No" answer for this list-id.
1893 no := store.RulesetNoListID{
1894 RcptToAddress: rcptTo,
1896 ToInbox: mbDst.Name == "Inbox",
1898 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1899 xcheckf(ctx, err, "looking up previous response for list-id")
1904 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1905 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1906 // are subscribed to, and take advantage of any reduced junk filtering.
1907 listIDDomStr := listIDDom.Name()
1909 doms := m.DKIMDomains
1910 if m.MailFromValidated {
1911 doms = append(doms, m.MailFromDomain)
1913 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1914 // of SPF verification of one host.
1915 sort.Slice(doms, func(i, j int) bool {
1916 return len(doms[i]) < len(doms[j])
1918 var listAllowDom string
1919 for _, dom := range doms {
1920 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1925 if listAllowDom == "" {
1929 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1930 nrs = &config.Ruleset{
1931 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1932 ListAllowDomain: listAllowDom,
1933 Mailbox: mbDst.Name,
1936 // Otherwise, try to make a rule based on message "From" address.
1937 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1940 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1942 no := store.RulesetNoMsgFrom{
1943 RcptToAddress: rcptTo,
1944 MsgFromAddress: msgFrom,
1945 ToInbox: mbDst.Name == "Inbox",
1947 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1948 xcheckf(ctx, err, "looking up previous response for message from address")
1953 nrs = &config.Ruleset{
1954 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1955 Mailbox: mbDst.Name,
1959 // Only suggest adding/removing rule if it isn't/is present.
1961 for _, rs := range dest.Rulesets {
1962 xrs := config.Ruleset{
1963 MsgFromRegexp: rs.MsgFromRegexp,
1964 HeadersRegexp: rs.HeadersRegexp,
1965 ListAllowDomain: rs.ListAllowDomain,
1966 Mailbox: nrs.Mailbox,
1968 if xrs.Equal(*nrs) {
1973 isRemove = mbDst.Name == defaultMailbox
1975 nrs.Mailbox = mbSrc.Name
1977 if isRemove && !have || !isRemove && have {
1981 // We'll be returning a suggested ruleset.
1982 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1988// Parse the list-id value (the value between <>) from a list-id header.
1989// Returns an empty string if it couldn't be parsed.
1990func parseListID(s string) (listID string, dom dns.Domain) {
1992 s = strings.TrimRight(s, " \t")
1993 if !strings.HasSuffix(s, ">") {
1994 return "", dns.Domain{}
1997 t := strings.Split(s, "<")
1999 return "", dns.Domain{}
2002 dom, err := dns.ParseDomain(s)
2009func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2010 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2012 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2013 dest, ok := acc.Destinations[rcptTo]
2015 // todo: we could find the catchall address and add the rule, or add the address explicitly.
2016 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")
2019 nd := map[string]config.Destination{}
2020 maps.Copy(nd, acc.Destinations)
2021 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2023 acc.Destinations = nd
2025 xcheckf(ctx, err, "saving account with new ruleset")
2028func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2029 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2031 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2032 dest, ok := acc.Destinations[rcptTo]
2034 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2037 nd := map[string]config.Destination{}
2038 maps.Copy(nd, acc.Destinations)
2039 var l []config.Ruleset
2041 for _, rs := range dest.Rulesets {
2042 if rs.Equal(ruleset) {
2049 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2053 acc.Destinations = nd
2055 xcheckf(ctx, err, "saving account with new ruleset")
2058func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2059 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2060 acc := reqInfo.Account
2064 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2066 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2068 xcheckf(ctx, err, "storing user response")
2071func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2072 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2073 acc := reqInfo.Account
2075 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2076 xcheckf(ctx, err, "storing user response")
2079func slicesAny[T any](l []T) []any {
2080 r := make([]any, len(l))
2081 for i, v := range l {
2087// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2088func (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) {