5 cryptorand "crypto/rand"
29 "golang.org/x/exp/maps"
31 "github.com/mjl-/bstore"
32 "github.com/mjl-/sherpa"
33 "github.com/mjl-/sherpadoc"
34 "github.com/mjl-/sherpaprom"
36 "github.com/mjl-/mox/admin"
37 "github.com/mjl-/mox/config"
38 "github.com/mjl-/mox/dkim"
39 "github.com/mjl-/mox/dns"
40 "github.com/mjl-/mox/message"
41 "github.com/mjl-/mox/metrics"
42 "github.com/mjl-/mox/mlog"
43 "github.com/mjl-/mox/mox-"
44 "github.com/mjl-/mox/moxio"
45 "github.com/mjl-/mox/moxvar"
46 "github.com/mjl-/mox/mtasts"
47 "github.com/mjl-/mox/mtastsdb"
48 "github.com/mjl-/mox/queue"
49 "github.com/mjl-/mox/smtp"
50 "github.com/mjl-/mox/smtpclient"
51 "github.com/mjl-/mox/store"
52 "github.com/mjl-/mox/webauth"
53 "github.com/mjl-/mox/webops"
57var webmailapiJSON []byte
60 maxMessageSize int64 // From listener.
61 cookiePath string // From listener.
62 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
65func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
66 err := json.Unmarshal(buf, &doc)
68 pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
73var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
75var sherpaHandlerOpts *sherpa.HandlerOpts
77func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
78 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
82 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
84 pkglog.Fatalx("creating sherpa prometheus collector", err)
87 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
89 _, err = makeSherpaHandler(0, "", false)
91 pkglog.Fatalx("sherpa handler", err)
95// LoginPrep returns a login token, and also sets it as cookie. Both must be
96// present in the call to Login.
97func (w Webmail) LoginPrep(ctx context.Context) string {
98 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
102 _, err := cryptorand.Read(data[:])
103 xcheckf(ctx, err, "generate token")
104 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
106 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
111// Login returns a session token for the credentials, or fails with error code
112// "user:badLogin". Call LoginPrep to get a loginToken.
113func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
114 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
117 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
118 if _, ok := err.(*sherpa.Error); ok {
121 xcheckf(ctx, err, "login")
125// Logout invalidates the session token.
126func (w Webmail) Logout(ctx context.Context) {
127 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
130 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.Account.Name, reqInfo.SessionToken)
131 xcheckf(ctx, err, "logout")
134// Token returns a single-use token to use for an SSE connection. A token can only
135// be used for a single SSE connection. Tokens are stored in memory for a maximum
136// of 1 minute, with at most 10 unused tokens (the most recently created) per
138func (Webmail) Token(ctx context.Context) string {
139 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
140 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
143// Requests sends a new request for an open SSE connection. Any currently active
144// request for the connection will be canceled, but this is done asynchrously, so
145// the SSE connection may still send results for the previous request. Callers
146// should take care to ignore such results. If req.Cancel is set, no new request is
148func (Webmail) Request(ctx context.Context, req Request) {
149 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
151 if !req.Cancel && req.Page.Count <= 0 {
152 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
155 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
157 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
162// ParsedMessage returns enough to render the textual body of a message. It is
163// assumed the client already has other fields through MessageItem.
164func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
165 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
167 acc := reqInfo.Account
169 xdbread(ctx, acc, func(tx *bstore.Tx) {
170 m := xmessageID(ctx, tx, msgID)
172 state := msgState{acc: acc}
175 pm, err = parsedMessage(log, m, &state, true, false, false)
176 xcheckf(ctx, err, "parsing message")
178 if len(pm.envelope.From) == 1 {
179 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
180 xcheckf(ctx, err, "looking up view mode for from address")
186// fromAddrViewMode returns the view mode for a from address.
187func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
188 settingsViewMode := func() (store.ViewMode, error) {
189 settings := store.Settings{ID: 1}
190 if err := tx.Get(&settings); err != nil {
191 return store.ModeText, err
193 if settings.ShowHTML {
194 return store.ModeHTML, nil
196 return store.ModeText, nil
199 lp, err := smtp.ParseLocalpart(from.User)
201 return settingsViewMode()
203 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
204 fas := store.FromAddressSettings{FromAddress: fromAddr}
206 if err == bstore.ErrAbsent {
207 return settingsViewMode()
208 } else if err != nil {
209 return store.ModeText, err
211 return fas.ViewMode, nil
214// FromAddressSettingsSave saves per-"From"-address settings.
215func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
216 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
217 acc := reqInfo.Account
219 if fas.FromAddress == "" {
220 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
223 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
224 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
225 err := tx.Update(&fas)
226 xcheckf(ctx, err, "updating settings for from address")
228 err := tx.Insert(&fas)
229 xcheckf(ctx, err, "inserting settings for from address")
234// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
235// of the message in storage. Used when opening a previously saved draft message
237// If no message is find, zero is returned, not an error.
238func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
239 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
240 acc := reqInfo.Account
242 messageID, _, _ = message.MessageIDCanonical(messageID)
244 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
247 xdbread(ctx, acc, func(tx *bstore.Tx) {
248 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
249 if err == bstore.ErrAbsent {
252 xcheckf(ctx, err, "looking up message by message-id")
258// ComposeMessage is a message to be composed, for saving draft messages.
259type ComposeMessage struct {
264 ReplyTo string // If non-empty, Reply-To header to add to message.
267 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
268 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
271// MessageCompose composes a message and saves it to the mailbox. Used for
272// saving draft messages.
273func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
274 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
275 acc := reqInfo.Account
278 log.Debug("message compose")
280 // Prevent any accidental control characters, or attempts at getting bare \r or \n
282 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
283 for _, s := range l {
284 for _, c := range s {
286 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
292 fromAddr, err := parseAddress(m.From)
293 xcheckuserf(ctx, err, "parsing From address")
295 var replyTo *message.NameAddress
297 addr, err := parseAddress(m.ReplyTo)
298 xcheckuserf(ctx, err, "parsing Reply-To address")
302 var recipients []smtp.Address
304 var toAddrs []message.NameAddress
305 for _, s := range m.To {
306 addr, err := parseAddress(s)
307 xcheckuserf(ctx, err, "parsing To address")
308 toAddrs = append(toAddrs, addr)
309 recipients = append(recipients, addr.Address)
312 var ccAddrs []message.NameAddress
313 for _, s := range m.Cc {
314 addr, err := parseAddress(s)
315 xcheckuserf(ctx, err, "parsing Cc address")
316 ccAddrs = append(ccAddrs, addr)
317 recipients = append(recipients, addr.Address)
320 var bccAddrs []message.NameAddress
321 for _, s := range m.Bcc {
322 addr, err := parseAddress(s)
323 xcheckuserf(ctx, err, "parsing Bcc address")
324 bccAddrs = append(bccAddrs, addr)
325 recipients = append(recipients, addr.Address)
328 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
330 for _, a := range recipients {
331 if a.Localpart.IsInternational() {
336 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
337 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
340 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
344 // Create file to compose message into.
345 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
346 xcheckf(ctx, err, "creating temporary file for compose message")
347 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
349 // If writing to the message file fails, we abort immediately.
350 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
356 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
357 xcheckuserf(ctx, err, "making message")
358 } else if ok && errors.Is(err, message.ErrCompose) {
359 xcheckf(ctx, err, "making message")
364 // Outer message headers.
365 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
367 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
369 xc.HeaderAddrs("To", toAddrs)
370 xc.HeaderAddrs("Cc", ccAddrs)
371 xc.HeaderAddrs("Bcc", bccAddrs)
373 xc.Subject(m.Subject)
376 // Add In-Reply-To and References headers.
377 if m.ResponseMessageID > 0 {
378 xdbread(ctx, acc, func(tx *bstore.Tx) {
379 rm := xmessageID(ctx, tx, m.ResponseMessageID)
380 msgr := acc.MessageReader(rm)
383 log.Check(err, "closing message reader")
385 rp, err := rm.LoadPart(msgr)
386 xcheckf(ctx, err, "load parsed message")
387 h, err := rp.Header()
388 xcheckf(ctx, err, "parsing header")
390 if rp.Envelope == nil {
394 if rp.Envelope.MessageID != "" {
395 xc.Header("In-Reply-To", rp.Envelope.MessageID)
397 refs := h.Values("References")
398 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
399 refs = []string{rp.Envelope.InReplyTo}
401 if rp.Envelope.MessageID != "" {
402 refs = append(refs, rp.Envelope.MessageID)
405 xc.Header("References", strings.Join(refs, "\r\n\t"))
409 xc.Header("MIME-Version", "1.0")
410 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
411 xc.Header("Content-Type", ct)
412 xc.Header("Content-Transfer-Encoding", cte)
414 xc.Write([]byte(textBody))
419 // Remove previous draft message, append message to destination mailbox.
420 acc.WithRLock(func() {
421 var changes []store.Change
423 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
424 var modseq store.ModSeq // Only set if needed.
426 if m.DraftMessageID > 0 {
427 var nchanges []store.Change
428 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
429 changes = append(changes, nchanges...)
430 // On-disk file is removed after lock.
433 // Find mailbox to write to.
434 mb := store.Mailbox{ID: mailboxID}
436 if err == bstore.ErrAbsent {
437 xcheckuserf(ctx, err, "looking up mailbox")
439 xcheckf(ctx, err, "looking up mailbox")
442 modseq, err = acc.NextModSeq(tx)
443 xcheckf(ctx, err, "next modseq")
450 MailboxOrigID: mb.ID,
451 Flags: store.Flags{Notjunk: true},
455 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
456 xcheckf(ctx, err, "checking quota")
458 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
461 // Update mailbox before delivery, which changes uidnext.
462 mb.Add(nm.MailboxCounts())
464 xcheckf(ctx, err, "updating sent mailbox for counts")
466 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
467 xcheckf(ctx, err, "storing message in mailbox")
469 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
472 store.BroadcastChanges(acc, changes)
475 // Remove on-disk file for removed draft message.
476 if m.DraftMessageID > 0 {
477 p := acc.MessagePath(m.DraftMessageID)
479 log.Check(err, "removing draft message file")
485// Attachment is a MIME part is an existing message that is not intended as
486// viewable text or HTML part.
487type Attachment struct {
488 Path []int // Indices into top-level message.Part.Parts.
490 // File name based on "name" attribute of "Content-Type", or the "filename"
491 // attribute of "Content-Disposition".
497// SubmitMessage is an email message to be sent to one or more recipients.
498// Addresses are formatted as just email address, or with a name like "name
500type SubmitMessage struct {
505 ReplyTo string // If non-empty, Reply-To header to add to message.
509 ForwardAttachments ForwardAttachments
511 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
512 UserAgent string // User-Agent header added if not empty.
513 RequireTLS *bool // For "Require TLS" extension during delivery.
514 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
515 ArchiveThread bool // If set, thread is archived after sending message.
516 ArchiveReferenceMailboxID int64 // If ArchiveThread is set, thread messages from this mailbox ID are moved to the archive mailbox ID. E.g. of Inbox.
517 DraftMessageID int64 // If set, draft message that will be removed after sending.
520// ForwardAttachments references attachments by a list of message.Part paths.
521type ForwardAttachments struct {
522 MessageID int64 // Only relevant if MessageID is not 0.
523 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
526// File is a new attachment (not from an existing message that is being
527// forwarded) to send with a SubmitMessage.
530 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
533// parseAddress expects either a plain email address like "user@domain", or a
534// single address as used in a message header, like "name <user@domain>".
535func parseAddress(msghdr string) (message.NameAddress, error) {
537 parser := mail.AddressParser{WordDecoder: &wordDecoder}
538 a, err := parser.Parse(msghdr)
540 return message.NameAddress{}, err
543 path, err := smtp.ParseNetMailAddress(a.Address)
545 return message.NameAddress{}, err
547 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
550func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
552 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
554 mb := store.Mailbox{ID: mailboxID}
556 if err == bstore.ErrAbsent {
557 xcheckuserf(ctx, err, "getting mailbox")
559 xcheckf(ctx, err, "getting mailbox")
563// xmessageID returns a non-expunged message or panics with a sherpa error.
564func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
566 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
568 m := store.Message{ID: messageID}
570 if err == bstore.ErrAbsent {
571 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
572 } else if err == nil && m.Expunged {
573 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
575 xcheckf(ctx, err, "getting message")
579func xrandomID(ctx context.Context, n int) string {
580 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
583func xrandom(ctx context.Context, n int) []byte {
584 buf := make([]byte, n)
585 x, err := cryptorand.Read(buf)
586 xcheckf(ctx, err, "read random")
588 xcheckf(ctx, errors.New("short random read"), "read random")
593// MessageSubmit sends a message by submitting it the outgoing email queue. The
594// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
595// Bcc message header.
597// If a Sent mailbox is configured, messages are added to it after submitting
598// to the delivery queue. If Bcc addresses were present, a header is prepended
599// to the message stored in the Sent mailbox.
600func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
601 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
602 acc := reqInfo.Account
605 log.Debug("message submit")
607 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
609 // todo: consider making this an HTTP POST, so we can upload as regular form, which is probably more efficient for encoding for the client and we can stream the data in. also not unlike the webapi Submit method.
611 // Prevent any accidental control characters, or attempts at getting bare \r or \n
613 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
614 for _, s := range l {
615 for _, c := range s {
617 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
623 fromAddr, err := parseAddress(m.From)
624 xcheckuserf(ctx, err, "parsing From address")
626 var replyTo *message.NameAddress
628 a, err := parseAddress(m.ReplyTo)
629 xcheckuserf(ctx, err, "parsing Reply-To address")
633 var recipients []smtp.Address
635 var toAddrs []message.NameAddress
636 for _, s := range m.To {
637 addr, err := parseAddress(s)
638 xcheckuserf(ctx, err, "parsing To address")
639 toAddrs = append(toAddrs, addr)
640 recipients = append(recipients, addr.Address)
643 var ccAddrs []message.NameAddress
644 for _, s := range m.Cc {
645 addr, err := parseAddress(s)
646 xcheckuserf(ctx, err, "parsing Cc address")
647 ccAddrs = append(ccAddrs, addr)
648 recipients = append(recipients, addr.Address)
651 var bccAddrs []message.NameAddress
652 for _, s := range m.Bcc {
653 addr, err := parseAddress(s)
654 xcheckuserf(ctx, err, "parsing Bcc address")
655 bccAddrs = append(bccAddrs, addr)
656 recipients = append(recipients, addr.Address)
659 // Check if from address is allowed for account.
660 if ok, disabled := mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address); disabled {
661 metricSubmission.WithLabelValues("domaindisabled").Inc()
662 xcheckuserf(ctx, mox.ErrDomainDisabled, `looking up "from" address for account`)
664 metricSubmission.WithLabelValues("badfrom").Inc()
665 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
668 if len(recipients) == 0 {
669 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
672 // Check outgoing message rate limit.
673 xdbread(ctx, acc, func(tx *bstore.Tx) {
674 rcpts := make([]smtp.Path, len(recipients))
675 for i, r := range recipients {
676 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
678 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
680 metricSubmission.WithLabelValues("messagelimiterror").Inc()
681 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
682 } else if rcptlimit >= 0 {
683 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
684 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
686 xcheckf(ctx, err, "checking send limit")
689 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
691 for _, a := range recipients {
692 if a.Localpart.IsInternational() {
697 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
698 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
701 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
705 // Create file to compose message into.
706 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
707 xcheckf(ctx, err, "creating temporary file for message")
708 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
710 // If writing to the message file fails, we abort immediately.
711 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
717 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
718 xcheckuserf(ctx, err, "making message")
719 } else if ok && errors.Is(err, message.ErrCompose) {
720 xcheckf(ctx, err, "making message")
725 // 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
727 // Each queued message gets a Received header.
728 // We don't have access to the local IP for adding.
729 // We cannot use VIA, because there is no registered method. We would like to use
730 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
731 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
732 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
733 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
734 recvHdrFor := func(rcptTo string) string {
735 recvHdr := &message.HeaderWriter{}
736 // For additional Received-header clauses, see:
737 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
738 // Note: we don't have "via" or "with", there is no registered for webmail.
739 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
740 if reqInfo.Request.TLS != nil {
741 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
743 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
744 return recvHdr.String()
747 // Outer message headers.
748 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
750 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
752 xc.HeaderAddrs("To", toAddrs)
753 xc.HeaderAddrs("Cc", ccAddrs)
754 // We prepend Bcc headers to the message when adding to the Sent mailbox.
756 xc.Subject(m.Subject)
759 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
760 xc.Header("Message-Id", messageID)
761 xc.Header("Date", time.Now().Format(message.RFC5322Z))
762 // Add In-Reply-To and References headers.
763 if m.ResponseMessageID > 0 {
764 xdbread(ctx, acc, func(tx *bstore.Tx) {
765 rm := xmessageID(ctx, tx, m.ResponseMessageID)
766 msgr := acc.MessageReader(rm)
769 log.Check(err, "closing message reader")
771 rp, err := rm.LoadPart(msgr)
772 xcheckf(ctx, err, "load parsed message")
773 h, err := rp.Header()
774 xcheckf(ctx, err, "parsing header")
776 if rp.Envelope == nil {
780 if rp.Envelope.MessageID != "" {
781 xc.Header("In-Reply-To", rp.Envelope.MessageID)
783 refs := h.Values("References")
784 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
785 refs = []string{rp.Envelope.InReplyTo}
787 if rp.Envelope.MessageID != "" {
788 refs = append(refs, rp.Envelope.MessageID)
791 xc.Header("References", strings.Join(refs, "\r\n\t"))
795 if m.UserAgent != "" {
796 xc.Header("User-Agent", m.UserAgent)
798 if m.RequireTLS != nil && !*m.RequireTLS {
799 xc.Header("TLS-Required", "No")
801 xc.Header("MIME-Version", "1.0")
803 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
804 mp := multipart.NewWriter(xc)
805 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
808 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
809 textHdr := textproto.MIMEHeader{}
810 textHdr.Set("Content-Type", ct)
811 textHdr.Set("Content-Transfer-Encoding", cte)
813 textp, err := mp.CreatePart(textHdr)
814 xcheckf(ctx, err, "adding text part to message")
815 _, err = textp.Write(textBody)
816 xcheckf(ctx, err, "writing text part")
818 xaddPart := func(ct, filename string) io.Writer {
819 ahdr := textproto.MIMEHeader{}
820 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
822 ahdr.Set("Content-Type", ct)
823 ahdr.Set("Content-Transfer-Encoding", "base64")
824 ahdr.Set("Content-Disposition", cd)
825 ap, err := mp.CreatePart(ahdr)
826 xcheckf(ctx, err, "adding attachment part to message")
830 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
831 ap := xaddPart(ct, filename)
833 for len(base64Data) > 0 {
839 line, base64Data = base64Data[:n], base64Data[n:]
840 _, err := ap.Write(line)
841 xcheckf(ctx, err, "writing attachment")
842 _, err = ap.Write([]byte("\r\n"))
843 xcheckf(ctx, err, "writing attachment")
847 xaddAttachment := func(ct, filename string, r io.Reader) {
848 ap := xaddPart(ct, filename)
849 wc := moxio.Base64Writer(ap)
850 _, err := io.Copy(wc, r)
851 xcheckf(ctx, err, "adding attachment")
853 xcheckf(ctx, err, "flushing attachment")
856 for _, a := range m.Attachments {
858 if !strings.HasPrefix(s, "data:") {
859 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
862 t := strings.SplitN(s, ",", 2)
864 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
866 if !strings.HasSuffix(t[0], "base64") {
867 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
869 ct := strings.TrimSuffix(t[0], "base64")
870 ct = strings.TrimSuffix(ct, ";")
872 ct = "application/octet-stream"
874 filename := a.Filename
876 filename = "unnamed.bin"
878 params := map[string]string{"name": filename}
879 ct = mime.FormatMediaType(ct, params)
881 // Ensure base64 is valid, then we'll write the original string.
882 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
883 xcheckuserf(ctx, err, "parsing attachment as base64")
885 xaddAttachmentBase64(ct, filename, []byte(t[1]))
888 if len(m.ForwardAttachments.Paths) > 0 {
889 acc.WithRLock(func() {
890 xdbread(ctx, acc, func(tx *bstore.Tx) {
891 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
892 msgr := acc.MessageReader(fm)
895 log.Check(err, "closing message reader")
898 fp, err := fm.LoadPart(msgr)
899 xcheckf(ctx, err, "load parsed message")
901 for _, path := range m.ForwardAttachments.Paths {
903 for _, xp := range path {
904 if xp < 0 || xp >= len(ap.Parts) {
905 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
910 _, filename, err := ap.DispositionFilename()
911 if err != nil && errors.Is(err, message.ErrParamEncoding) {
912 log.Debugx("parsing disposition/filename", err)
914 xcheckf(ctx, err, "reading disposition")
917 filename = "unnamed.bin"
919 params := map[string]string{"name": filename}
920 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
921 params["charset"] = pcharset
923 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
924 ct = mime.FormatMediaType(ct, params)
925 xaddAttachment(ct, filename, ap.Reader())
932 xcheckf(ctx, err, "writing mime multipart")
934 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
935 xc.Header("Content-Type", ct)
936 xc.Header("Content-Transfer-Encoding", cte)
938 xc.Write([]byte(textBody))
943 // Add DKIM-Signature headers.
945 fd := fromAddr.Address.Domain
946 confDom, _ := mox.Conf.Domain(fd)
947 if confDom.Disabled {
948 xcheckuserf(ctx, mox.ErrDomainDisabled, "checking domain")
950 selectors := mox.DKIMSelectors(confDom.DKIM)
951 if len(selectors) > 0 {
952 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
954 metricServerErrors.WithLabelValues("dkimsign").Inc()
956 xcheckf(ctx, err, "sign dkim")
958 msgPrefix = dkimHeaders
961 accConf, _ := acc.Conf()
962 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
963 xcheckf(ctx, err, "parsing login address")
964 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
965 fromPath := fromAddr.Address.Path()
966 var localpartBase string
968 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
970 qml := make([]queue.Msg, len(recipients))
972 for i, rcpt := range recipients {
976 fromID = xrandomID(ctx, 16)
977 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
980 // Don't use per-recipient unique message prefix when multiple recipients are
981 // present, or the queue cannot deliver it in a single smtp transaction.
983 if len(recipients) == 1 {
984 recvRcpt = rcpt.Pack(smtputf8)
986 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
987 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
989 Localpart: rcpt.Localpart,
990 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
992 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
993 if m.FutureRelease != nil {
994 ival := time.Until(*m.FutureRelease)
996 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
997 } else if ival > queue.FutureReleaseIntervalMax {
998 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
1000 qm.NextAttempt = *m.FutureRelease
1001 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
1002 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
1005 // no qm.Extra from webmail
1008 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
1010 metricSubmission.WithLabelValues("queueerror").Inc()
1012 xcheckf(ctx, err, "adding messages to the delivery queue")
1013 metricSubmission.WithLabelValues("ok").Inc()
1015 var modseq store.ModSeq // Only set if needed.
1017 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1018 // remove any draft message.
1019 acc.WithRLock(func() {
1020 var changes []store.Change
1024 if x := recover(); x != nil {
1026 metricServerErrors.WithLabelValues("submit").Inc()
1031 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1032 if m.DraftMessageID > 0 {
1033 var nchanges []store.Change
1034 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1035 changes = append(changes, nchanges...)
1036 // On-disk file is removed after lock.
1039 if m.ResponseMessageID > 0 {
1040 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1047 if !rm.Junk && !rm.Notjunk {
1050 if rm.Flags != oflags {
1051 modseq, err = acc.NextModSeq(tx)
1052 xcheckf(ctx, err, "next modseq")
1054 err := tx.Update(&rm)
1055 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1056 changes = append(changes, rm.ChangeFlags(oflags))
1058 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1059 xcheckf(ctx, err, "retraining messages after reply/forward")
1062 // Move messages from this thread still in this mailbox to the designated Archive
1064 if m.ArchiveThread {
1065 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1066 if err == bstore.ErrAbsent {
1067 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1069 xcheckf(ctx, err, "looking up designated archive mailbox")
1072 q := bstore.QueryTx[store.Message](tx)
1073 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1074 q.FilterEqual("Expunged", false)
1075 err = q.IDs(&msgIDs)
1076 xcheckf(ctx, err, "listing messages in thread to archive")
1077 if len(msgIDs) > 0 {
1078 var nchanges []store.Change
1079 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1080 changes = append(changes, nchanges...)
1085 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1086 if err == bstore.ErrAbsent {
1087 // There is no mailbox designated as Sent mailbox, so we're done.
1090 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1093 modseq, err = acc.NextModSeq(tx)
1094 xcheckf(ctx, err, "next modseq")
1097 // If there were bcc headers, prepend those to the stored message only, before the
1098 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1099 // message won't validate with DKIM anymore, which is fine.
1100 if len(bccAddrs) > 0 {
1101 var sb strings.Builder
1102 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1103 xbcc.HeaderAddrs("Bcc", bccAddrs)
1105 msgPrefix = sb.String() + msgPrefix
1108 sentm := store.Message{
1111 MailboxID: sentmb.ID,
1112 MailboxOrigID: sentmb.ID,
1113 Flags: store.Flags{Notjunk: true, Seen: true},
1114 Size: int64(len(msgPrefix)) + xc.Size,
1115 MsgPrefix: []byte(msgPrefix),
1118 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1119 xcheckf(ctx, err, "checking quota")
1121 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1124 // Update mailbox before delivery, which changes uidnext.
1125 sentmb.Add(sentm.MailboxCounts())
1126 err = tx.Update(&sentmb)
1127 xcheckf(ctx, err, "updating sent mailbox for counts")
1129 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1131 metricSubmission.WithLabelValues("storesenterror").Inc()
1134 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1136 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1139 store.BroadcastChanges(acc, changes)
1142 // Remove on-disk file for removed draft message.
1143 if m.DraftMessageID > 0 {
1144 p := acc.MessagePath(m.DraftMessageID)
1146 log.Check(err, "removing draft message file")
1150// MessageMove moves messages to another mailbox. If the message is already in
1151// the mailbox an error is returned.
1152func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1153 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1154 acc := reqInfo.Account
1157 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1160var xops = webops.XOps{
1163 Checkuserf: xcheckuserf,
1166// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1167func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1168 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1169 acc := reqInfo.Account
1172 if len(messageIDs) == 0 {
1176 xops.MessageDelete(ctx, log, acc, messageIDs)
1179// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1180// flags should be lower-case, but will be converted and verified.
1181func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1182 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1183 acc := reqInfo.Account
1186 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1189// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1190func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1191 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1192 acc := reqInfo.Account
1195 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1198// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1199// not automatically included, they must explicitly be included in the list of IDs.
1200func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1201 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1202 acc := reqInfo.Account
1205 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1208// MailboxCreate creates a new mailbox.
1209func (Webmail) MailboxCreate(ctx context.Context, name string) {
1210 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1211 acc := reqInfo.Account
1214 name, _, err = store.CheckMailboxName(name, false)
1215 xcheckuserf(ctx, err, "checking mailbox name")
1217 acc.WithWLock(func() {
1218 var changes []store.Change
1219 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1222 changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1224 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1226 xcheckf(ctx, err, "creating mailbox")
1229 store.BroadcastChanges(acc, changes)
1233// MailboxDelete deletes a mailbox and all its messages and annotations.
1234func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1235 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1236 acc := reqInfo.Account
1239 // Messages to remove after having broadcasted the removal of messages.
1240 var removeMessageIDs []int64
1242 acc.WithWLock(func() {
1243 var changes []store.Change
1245 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1246 mb := xmailboxID(ctx, tx, mailboxID)
1247 if mb.Name == "Inbox" {
1248 // Inbox is special in IMAP and cannot be removed.
1249 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1252 var hasChildren bool
1254 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1256 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1258 xcheckf(ctx, err, "deleting mailbox")
1261 store.BroadcastChanges(acc, changes)
1264 for _, mID := range removeMessageIDs {
1265 p := acc.MessagePath(mID)
1267 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1271// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1272// its child mailboxes.
1273func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1274 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1275 acc := reqInfo.Account
1278 var expunged []store.Message
1280 acc.WithWLock(func() {
1281 var changes []store.Change
1283 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1284 mb := xmailboxID(ctx, tx, mailboxID)
1286 modseq, err := acc.NextModSeq(tx)
1287 xcheckf(ctx, err, "next modseq")
1289 // Mark messages as expunged.
1290 qm := bstore.QueryTx[store.Message](tx)
1291 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1292 qm.FilterEqual("Expunged", false)
1294 qm.Gather(&expunged)
1295 n, err := qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1296 xcheckf(ctx, err, "deleting messages")
1299 xcheckf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1302 // Remove Recipients.
1303 anyIDs := make([]any, len(expunged))
1304 for i, m := range expunged {
1307 qmr := bstore.QueryTx[store.Recipient](tx)
1308 qmr.FilterEqual("MessageID", anyIDs...)
1309 _, err = qmr.Delete()
1310 xcheckf(ctx, err, "removing message recipients")
1312 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1314 uids := make([]store.UID, len(expunged))
1315 for i, m := range expunged {
1316 m.Expunged = false // Gather returns updated values.
1317 mb.Sub(m.MailboxCounts())
1321 expunged[i].Junk = false
1322 expunged[i].Notjunk = false
1325 err = tx.Update(&mb)
1326 xcheckf(ctx, err, "updating mailbox for counts")
1328 err = acc.AddMessageSize(log, tx, -totalSize)
1329 xcheckf(ctx, err, "updating disk usage")
1331 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1332 xcheckf(ctx, err, "retraining expunged messages")
1334 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1335 changes = []store.Change{chremove, mb.ChangeCounts()}
1338 store.BroadcastChanges(acc, changes)
1341 for _, m := range expunged {
1342 p := acc.MessagePath(m.ID)
1344 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1348// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1349// ID and its messages are unchanged.
1350func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1351 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1352 acc := reqInfo.Account
1354 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1355 // standard. We can just say no.
1357 newName, _, err = store.CheckMailboxName(newName, false)
1358 xcheckuserf(ctx, err, "checking new mailbox name")
1360 acc.WithWLock(func() {
1361 var changes []store.Change
1363 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1364 mbsrc := xmailboxID(ctx, tx, mailboxID)
1366 var isInbox, notExists, alreadyExists bool
1367 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1368 if isInbox || notExists || alreadyExists {
1369 xcheckuserf(ctx, err, "renaming mailbox")
1371 xcheckf(ctx, err, "renaming mailbox")
1374 store.BroadcastChanges(acc, changes)
1378// CompleteRecipient returns autocomplete matches for a recipient, returning the
1379// matches, most recently used first, and whether this is the full list and further
1380// requests for longer prefixes aren't necessary.
1381func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1382 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1383 acc := reqInfo.Account
1385 search = strings.ToLower(search)
1387 var matches []string
1389 acc.WithRLock(func() {
1390 xdbread(ctx, acc, func(tx *bstore.Tx) {
1395 seen := map[key]bool{}
1397 q := bstore.QueryTx[store.Recipient](tx)
1399 err := q.ForEach(func(r store.Recipient) error {
1400 k := key{r.Localpart, r.Domain}
1404 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1405 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1406 if !strings.Contains(strings.ToLower(address), search) {
1409 if len(matches) >= 20 {
1411 return bstore.StopForEach
1414 // Look in the message that was sent for a name along with the address.
1415 m := store.Message{ID: r.MessageID}
1417 xcheckf(ctx, err, "get sent message")
1418 if !m.Expunged && m.ParsedBuf != nil {
1419 var part message.Part
1420 err := json.Unmarshal(m.ParsedBuf, &part)
1421 xcheckf(ctx, err, "parsing part")
1423 dom, err := dns.ParseDomain(r.Domain)
1424 xcheckf(ctx, err, "parsing domain of recipient")
1428 checkAddrs := func(l []message.Address) {
1432 for _, a := range l {
1433 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1435 address = addressString(a, false)
1440 if part.Envelope != nil {
1441 env := part.Envelope
1448 matches = append(matches, address)
1452 xcheckf(ctx, err, "listing recipients")
1458// addressString returns an address into a string as it could be used in a message header.
1459func addressString(a message.Address, smtputf8 bool) string {
1461 dom, err := dns.ParseDomain(a.Host)
1463 if smtputf8 && dom.Unicode != "" {
1469 s := "<" + a.User + "@" + host + ">"
1471 // todo: properly encoded/escaped name
1472 s = a.Name + " " + s
1477// MailboxSetSpecialUse sets the special use flags of a mailbox.
1478func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1479 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1480 acc := reqInfo.Account
1482 acc.WithWLock(func() {
1483 var changes []store.Change
1485 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1486 xmb := xmailboxID(ctx, tx, mb.ID)
1488 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1489 // we set, we clear it for the mailbox(es) that had it, if any.
1490 clearPrevious := func(clear bool, specialUse string) {
1494 var ombl []store.Mailbox
1495 q := bstore.QueryTx[store.Mailbox](tx)
1496 q.FilterNotEqual("ID", mb.ID)
1497 q.FilterEqual(specialUse, true)
1499 _, err := q.UpdateField(specialUse, false)
1500 xcheckf(ctx, err, "updating previous special-use mailboxes")
1502 for _, omb := range ombl {
1503 changes = append(changes, omb.ChangeSpecialUse())
1506 clearPrevious(mb.Archive, "Archive")
1507 clearPrevious(mb.Draft, "Draft")
1508 clearPrevious(mb.Junk, "Junk")
1509 clearPrevious(mb.Sent, "Sent")
1510 clearPrevious(mb.Trash, "Trash")
1512 xmb.SpecialUse = mb.SpecialUse
1513 err := tx.Update(&xmb)
1514 xcheckf(ctx, err, "updating special-use flags for mailbox")
1515 changes = append(changes, xmb.ChangeSpecialUse())
1518 store.BroadcastChanges(acc, changes)
1522// ThreadCollapse saves the ThreadCollapse field for the messages and its
1523// children. The messageIDs are typically thread roots. But not all roots
1524// (without parent) of a thread need to have the same collapsed state.
1525func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1526 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1527 acc := reqInfo.Account
1529 if len(messageIDs) == 0 {
1530 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1533 acc.WithWLock(func() {
1534 changes := make([]store.Change, 0, len(messageIDs))
1535 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1536 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1537 // (child) messages. Further refined in FilterFn.
1538 threadIDs := map[int64]struct{}{}
1539 msgIDs := map[int64]struct{}{}
1540 for _, id := range messageIDs {
1541 m := store.Message{ID: id}
1543 if err == bstore.ErrAbsent {
1544 xcheckuserf(ctx, err, "get message")
1546 xcheckf(ctx, err, "get message")
1547 threadIDs[m.ThreadID] = struct{}{}
1548 msgIDs[id] = struct{}{}
1551 var updated []store.Message
1552 q := bstore.QueryTx[store.Message](tx)
1553 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1554 q.FilterNotEqual("ThreadCollapsed", collapse)
1555 q.FilterFn(func(tm store.Message) bool {
1556 for _, id := range tm.ThreadParentIDs {
1557 if _, ok := msgIDs[id]; ok {
1561 _, ok := msgIDs[tm.ID]
1565 q.SortAsc("ID") // Consistent order for testing.
1566 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1567 xcheckf(ctx, err, "updating collapse in database")
1569 for _, m := range updated {
1570 changes = append(changes, m.ChangeThread())
1573 store.BroadcastChanges(acc, changes)
1577// ThreadMute saves the ThreadMute field for the messages and their children.
1578// If messages are muted, they are also marked collapsed.
1579func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1580 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1581 acc := reqInfo.Account
1583 if len(messageIDs) == 0 {
1584 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1587 acc.WithWLock(func() {
1588 changes := make([]store.Change, 0, len(messageIDs))
1589 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1590 threadIDs := map[int64]struct{}{}
1591 msgIDs := map[int64]struct{}{}
1592 for _, id := range messageIDs {
1593 m := store.Message{ID: id}
1595 if err == bstore.ErrAbsent {
1596 xcheckuserf(ctx, err, "get message")
1598 xcheckf(ctx, err, "get message")
1599 threadIDs[m.ThreadID] = struct{}{}
1600 msgIDs[id] = struct{}{}
1603 var updated []store.Message
1605 q := bstore.QueryTx[store.Message](tx)
1606 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1607 q.FilterFn(func(tm store.Message) bool {
1608 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1611 for _, id := range tm.ThreadParentIDs {
1612 if _, ok := msgIDs[id]; ok {
1616 _, ok := msgIDs[tm.ID]
1620 fields := map[string]any{"ThreadMuted": mute}
1622 fields["ThreadCollapsed"] = true
1624 _, err := q.UpdateFields(fields)
1625 xcheckf(ctx, err, "updating mute in database")
1627 for _, m := range updated {
1628 changes = append(changes, m.ChangeThread())
1631 store.BroadcastChanges(acc, changes)
1635// SecurityResult indicates whether a security feature is supported.
1636type SecurityResult string
1639 SecurityResultError SecurityResult = "error"
1640 SecurityResultNo SecurityResult = "no"
1641 SecurityResultYes SecurityResult = "yes"
1642 // Unknown whether supported. Finding out may only be (reasonably) possible when
1643 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1645 SecurityResultUnknown SecurityResult = "unknown"
1648// RecipientSecurity is a quick analysis of the security properties of delivery to
1649// the recipient (domain).
1650type RecipientSecurity struct {
1651 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1652 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1654 STARTTLS SecurityResult
1656 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1658 MTASTS SecurityResult
1660 // Whether MX lookup response was DNSSEC-signed.
1661 DNSSEC SecurityResult
1663 // Whether first delivery destination has DANE records.
1666 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1667 // Will be "unknown" if no delivery to the domain has been attempted yet.
1668 RequireTLS SecurityResult
1671// RecipientSecurity looks up security properties of the address in the
1672// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1673func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1674 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1677 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1678 return recipientSecurity(ctx, log, resolver, messageAddressee)
1681// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1682func logPanic(ctx context.Context) {
1687 log := pkglog.WithContext(ctx)
1688 log.Error("recover from panic", slog.Any("panic", x))
1690 metrics.PanicInc(metrics.Webmail)
1693// separate function for testing with mocked resolver.
1694func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1695 rs := RecipientSecurity{
1696 SecurityResultUnknown,
1697 SecurityResultUnknown,
1698 SecurityResultUnknown,
1699 SecurityResultUnknown,
1700 SecurityResultUnknown,
1703 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1704 msgAddr, err := parser.Parse(messageAddressee)
1706 return rs, fmt.Errorf("parsing addressee: %v", err)
1708 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1710 return rs, fmt.Errorf("parsing address: %v", err)
1713 var wg sync.WaitGroup
1721 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1722 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1723 rs.MTASTS = SecurityResultYes
1724 } else if err == nil {
1725 rs.MTASTS = SecurityResultNo
1727 rs.MTASTS = SecurityResultError
1737 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1739 rs.DNSSEC = SecurityResultError
1742 if origNextHopAuthentic && expandedNextHopAuthentic {
1743 rs.DNSSEC = SecurityResultYes
1745 rs.DNSSEC = SecurityResultNo
1748 if !origNextHopAuthentic {
1749 rs.DANE = SecurityResultNo
1753 // We're only looking at the first host to deliver to (typically first mx destination).
1754 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1755 return // Should not happen.
1759 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1760 // error result instead of no-DANE result.
1761 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1763 rs.DANE = SecurityResultError
1767 rs.DANE = SecurityResultNo
1771 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1773 rs.DANE = SecurityResultError
1775 } else if daneRequired {
1776 rs.DANE = SecurityResultYes
1778 rs.DANE = SecurityResultNo
1782 // STARTTLS and RequireTLS
1783 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1784 acc := reqInfo.Account
1786 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1787 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1788 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1790 if err == bstore.ErrAbsent {
1792 } else if err != nil {
1793 rs.STARTTLS = SecurityResultError
1794 rs.RequireTLS = SecurityResultError
1795 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1799 rs.STARTTLS = SecurityResultYes
1801 rs.STARTTLS = SecurityResultNo
1804 rs.RequireTLS = SecurityResultYes
1806 rs.RequireTLS = SecurityResultNo
1810 xcheckf(ctx, err, "lookup recipient domain")
1817// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1818func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1819 s, err := wordDecoder.DecodeHeader(text)
1820 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1824// SettingsSave saves settings, e.g. for composing.
1825func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1826 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1827 acc := reqInfo.Account
1830 err := acc.DB.Update(ctx, &settings)
1831 xcheckf(ctx, err, "save settings")
1834func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1835 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1836 acc := reqInfo.Account
1839 xdbread(ctx, acc, func(tx *bstore.Tx) {
1840 m := xmessageID(ctx, tx, msgID)
1841 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1842 mbDst := xmailboxID(ctx, tx, mbDstID)
1844 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1847 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1849 conf, _ := acc.Conf()
1850 dest := conf.Destinations[rcptTo] // May not be present.
1851 defaultMailbox := "Inbox"
1852 if dest.Mailbox != "" {
1853 defaultMailbox = dest.Mailbox
1856 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1857 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1861 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1862 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1863 xcheckf(ctx, err, "looking up previous response for source mailbox")
1867 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1868 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1873 // Parse message for List-Id header.
1874 state := msgState{acc: acc}
1876 pm, err := parsedMessage(log, m, &state, true, false, false)
1877 xcheckf(ctx, err, "parsing message")
1879 // The suggested ruleset. Once all is checked, we'll return it.
1880 var nrs *config.Ruleset
1882 // If List-Id header is present, we'll treat it as a (mailing) list message.
1883 if l, ok := pm.Headers["List-Id"]; ok {
1885 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1888 var listIDDom dns.Domain
1889 listID, listIDDom = parseListID(l[0])
1891 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1895 // Check if we have a previous "No" answer for this list-id.
1896 no := store.RulesetNoListID{
1897 RcptToAddress: rcptTo,
1899 ToInbox: mbDst.Name == "Inbox",
1901 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1902 xcheckf(ctx, err, "looking up previous response for list-id")
1907 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1908 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1909 // are subscribed to, and take advantage of any reduced junk filtering.
1910 listIDDomStr := listIDDom.Name()
1912 doms := m.DKIMDomains
1913 if m.MailFromValidated {
1914 doms = append(doms, m.MailFromDomain)
1916 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1917 // of SPF verification of one host.
1918 sort.Slice(doms, func(i, j int) bool {
1919 return len(doms[i]) < len(doms[j])
1921 var listAllowDom string
1922 for _, dom := range doms {
1923 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1928 if listAllowDom == "" {
1932 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1933 nrs = &config.Ruleset{
1934 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1935 ListAllowDomain: listAllowDom,
1936 Mailbox: mbDst.Name,
1939 // Otherwise, try to make a rule based on message "From" address.
1940 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1943 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1945 no := store.RulesetNoMsgFrom{
1946 RcptToAddress: rcptTo,
1947 MsgFromAddress: msgFrom,
1948 ToInbox: mbDst.Name == "Inbox",
1950 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1951 xcheckf(ctx, err, "looking up previous response for message from address")
1956 nrs = &config.Ruleset{
1957 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1958 Mailbox: mbDst.Name,
1962 // Only suggest adding/removing rule if it isn't/is present.
1964 for _, rs := range dest.Rulesets {
1965 xrs := config.Ruleset{
1966 MsgFromRegexp: rs.MsgFromRegexp,
1967 HeadersRegexp: rs.HeadersRegexp,
1968 ListAllowDomain: rs.ListAllowDomain,
1969 Mailbox: nrs.Mailbox,
1971 if xrs.Equal(*nrs) {
1976 isRemove = mbDst.Name == defaultMailbox
1978 nrs.Mailbox = mbSrc.Name
1980 if isRemove && !have || !isRemove && have {
1984 // We'll be returning a suggested ruleset.
1985 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1991// Parse the list-id value (the value between <>) from a list-id header.
1992// Returns an empty string if it couldn't be parsed.
1993func parseListID(s string) (listID string, dom dns.Domain) {
1995 s = strings.TrimRight(s, " \t")
1996 if !strings.HasSuffix(s, ">") {
1997 return "", dns.Domain{}
2000 t := strings.Split(s, "<")
2002 return "", dns.Domain{}
2005 dom, err := dns.ParseDomain(s)
2012func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2013 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2015 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2016 dest, ok := acc.Destinations[rcptTo]
2018 // todo: we could find the catchall address and add the rule, or add the address explicitly.
2019 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")
2022 nd := map[string]config.Destination{}
2023 for addr, d := range acc.Destinations {
2026 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2028 acc.Destinations = nd
2030 xcheckf(ctx, err, "saving account with new ruleset")
2033func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2034 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2036 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2037 dest, ok := acc.Destinations[rcptTo]
2039 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2042 nd := map[string]config.Destination{}
2043 for addr, d := range acc.Destinations {
2046 var l []config.Ruleset
2048 for _, rs := range dest.Rulesets {
2049 if rs.Equal(ruleset) {
2056 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2060 acc.Destinations = nd
2062 xcheckf(ctx, err, "saving account with new ruleset")
2065func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2066 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2067 acc := reqInfo.Account
2071 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2073 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2075 xcheckf(ctx, err, "storing user response")
2078func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2079 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2080 acc := reqInfo.Account
2082 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2083 xcheckf(ctx, err, "storing user response")
2086func slicesAny[T any](l []T) []any {
2087 r := make([]any, len(l))
2088 for i, v := range l {
2094// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2095func (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) {