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// ParsedMessage returns enough to render the textual body of a message. It is
167// assumed the client already has other fields through MessageItem.
168func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
169 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
171 acc := reqInfo.Account
173 xdbread(ctx, acc, func(tx *bstore.Tx) {
174 m := xmessageID(ctx, tx, msgID)
176 state := msgState{acc: acc}
179 pm, err = parsedMessage(log, &m, &state, true, false, false)
180 xcheckf(ctx, err, "parsing message")
182 if len(pm.envelope.From) == 1 {
183 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
184 xcheckf(ctx, err, "looking up view mode for from address")
190// fromAddrViewMode returns the view mode for a from address.
191func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
192 settingsViewMode := func() (store.ViewMode, error) {
193 settings := store.Settings{ID: 1}
194 if err := tx.Get(&settings); err != nil {
195 return store.ModeText, err
197 if settings.ShowHTML {
198 return store.ModeHTML, nil
200 return store.ModeText, nil
203 lp, err := smtp.ParseLocalpart(from.User)
205 return settingsViewMode()
207 fromAddr := smtp.NewAddress(lp, from.Domain).Pack(true)
208 fas := store.FromAddressSettings{FromAddress: fromAddr}
210 if err == bstore.ErrAbsent {
211 return settingsViewMode()
212 } else if err != nil {
213 return store.ModeText, err
215 return fas.ViewMode, nil
218// FromAddressSettingsSave saves per-"From"-address settings.
219func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
220 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
221 acc := reqInfo.Account
223 if fas.FromAddress == "" {
224 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
227 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
228 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
229 err := tx.Update(&fas)
230 xcheckf(ctx, err, "updating settings for from address")
232 err := tx.Insert(&fas)
233 xcheckf(ctx, err, "inserting settings for from address")
238// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
239// of the message in storage. Used when opening a previously saved draft message
241// If no message is find, zero is returned, not an error.
242func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
243 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
244 acc := reqInfo.Account
246 messageID, _, _ = message.MessageIDCanonical(messageID)
248 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
251 xdbread(ctx, acc, func(tx *bstore.Tx) {
252 q := bstore.QueryTx[store.Message](tx)
253 q.FilterEqual("Expunged", false)
254 q.FilterNonzero(store.Message{MessageID: messageID})
256 if err == bstore.ErrAbsent {
259 xcheckf(ctx, err, "looking up message by message-id")
265// ComposeMessage is a message to be composed, for saving draft messages.
266type ComposeMessage struct {
271 ReplyTo string // If non-empty, Reply-To header to add to message.
274 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
275 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
278// MessageCompose composes a message and saves it to the mailbox. Used for
279// saving draft messages.
280func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
281 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
282 acc := reqInfo.Account
285 log.Debug("message compose")
287 // Prevent any accidental control characters, or attempts at getting bare \r or \n
289 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
290 for _, s := range l {
291 for _, c := range s {
293 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
299 fromAddr, err := parseAddress(m.From)
300 xcheckuserf(ctx, err, "parsing From address")
302 var replyTo *message.NameAddress
304 addr, err := parseAddress(m.ReplyTo)
305 xcheckuserf(ctx, err, "parsing Reply-To address")
309 var recipients []smtp.Address
311 var toAddrs []message.NameAddress
312 for _, s := range m.To {
313 addr, err := parseAddress(s)
314 xcheckuserf(ctx, err, "parsing To address")
315 toAddrs = append(toAddrs, addr)
316 recipients = append(recipients, addr.Address)
319 var ccAddrs []message.NameAddress
320 for _, s := range m.Cc {
321 addr, err := parseAddress(s)
322 xcheckuserf(ctx, err, "parsing Cc address")
323 ccAddrs = append(ccAddrs, addr)
324 recipients = append(recipients, addr.Address)
327 var bccAddrs []message.NameAddress
328 for _, s := range m.Bcc {
329 addr, err := parseAddress(s)
330 xcheckuserf(ctx, err, "parsing Bcc address")
331 bccAddrs = append(bccAddrs, addr)
332 recipients = append(recipients, addr.Address)
335 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
337 for _, a := range recipients {
338 if a.Localpart.IsInternational() {
343 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
344 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
347 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
351 // Create file to compose message into.
352 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
353 xcheckf(ctx, err, "creating temporary file for compose message")
354 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
356 // If writing to the message file fails, we abort immediately.
357 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
363 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
364 xcheckuserf(ctx, err, "making message")
365 } else if ok && errors.Is(err, message.ErrCompose) {
366 xcheckf(ctx, err, "making message")
371 // Outer message headers.
372 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
374 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
376 xc.HeaderAddrs("To", toAddrs)
377 xc.HeaderAddrs("Cc", ccAddrs)
378 xc.HeaderAddrs("Bcc", bccAddrs)
380 xc.Subject(m.Subject)
383 // Add In-Reply-To and References headers.
384 if m.ResponseMessageID > 0 {
385 xdbread(ctx, acc, func(tx *bstore.Tx) {
386 rm := xmessageID(ctx, tx, m.ResponseMessageID)
387 msgr := acc.MessageReader(rm)
390 log.Check(err, "closing message reader")
392 rp, err := rm.LoadPart(msgr)
393 xcheckf(ctx, err, "load parsed message")
394 h, err := rp.Header()
395 xcheckf(ctx, err, "parsing header")
397 if rp.Envelope == nil {
401 if rp.Envelope.MessageID != "" {
402 xc.Header("In-Reply-To", rp.Envelope.MessageID)
404 refs := h.Values("References")
405 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
406 refs = []string{rp.Envelope.InReplyTo}
408 if rp.Envelope.MessageID != "" {
409 refs = append(refs, rp.Envelope.MessageID)
412 xc.Header("References", strings.Join(refs, "\r\n\t"))
416 xc.Header("MIME-Version", "1.0")
417 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
418 xc.Header("Content-Type", ct)
419 xc.Header("Content-Transfer-Encoding", cte)
421 xc.Write([]byte(textBody))
426 // Remove previous draft message, append message to destination mailbox.
427 acc.WithWLock(func() {
428 var changes []store.Change
432 for _, id := range newIDs {
433 p := acc.MessagePath(id)
435 log.Check(err, "removing added message aftr error", slog.String("path", p))
439 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
440 var modseq store.ModSeq // Only set if needed.
442 if m.DraftMessageID > 0 {
443 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
444 changes = append(changes, nchanges...)
447 mb, err := store.MailboxID(tx, mailboxID)
448 xcheckf(ctx, err, "looking up mailbox")
451 modseq, err = acc.NextModSeq(tx)
452 xcheckf(ctx, err, "next modseq")
459 MailboxOrigID: mb.ID,
460 Flags: store.Flags{Notjunk: true},
464 err = acc.MessageAdd(log, tx, &mb, &nm, dataFile, store.AddOpts{})
465 if err != nil && errors.Is(err, store.ErrOverQuota) {
466 xcheckuserf(ctx, err, "checking quota")
468 xcheckf(ctx, err, "storing message in mailbox")
469 newIDs = append(newIDs, nm.ID)
472 xcheckf(ctx, err, "updating sent mailbox for counts")
474 changes = append(changes, nm.ChangeAddUID(mb), mb.ChangeCounts())
478 store.BroadcastChanges(acc, changes)
484// Attachment is a MIME part is an existing message that is not intended as
485// viewable text or HTML part.
486type Attachment struct {
487 Path []int // Indices into top-level message.Part.Parts.
489 // File name based on "name" attribute of "Content-Type", or the "filename"
490 // attribute of "Content-Disposition".
496// SubmitMessage is an email message to be sent to one or more recipients.
497// Addresses are formatted as just email address, or with a name like "name
499type SubmitMessage struct {
504 ReplyTo string // If non-empty, Reply-To header to add to message.
508 ForwardAttachments ForwardAttachments
510 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
511 UserAgent string // User-Agent header added if not empty.
512 RequireTLS *bool // For "Require TLS" extension during delivery.
513 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
514 ArchiveThread bool // If set, thread is archived after sending message.
515 ArchiveReferenceMailboxID int64 // If ArchiveThread is set, thread messages from this mailbox ID are moved to the archive mailbox ID. E.g. of Inbox.
516 DraftMessageID int64 // If set, draft message that will be removed after sending.
519// ForwardAttachments references attachments by a list of message.Part paths.
520type ForwardAttachments struct {
521 MessageID int64 // Only relevant if MessageID is not 0.
522 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
525// File is a new attachment (not from an existing message that is being
526// forwarded) to send with a SubmitMessage.
529 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
532// parseAddress expects either a plain email address like "user@domain", or a
533// single address as used in a message header, like "name <user@domain>".
534func parseAddress(msghdr string) (message.NameAddress, error) {
536 parser := mail.AddressParser{WordDecoder: &wordDecoder}
537 a, err := parser.Parse(msghdr)
539 return message.NameAddress{}, err
542 path, err := smtp.ParseNetMailAddress(a.Address)
544 return message.NameAddress{}, err
546 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
549func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
551 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
553 mb, err := store.MailboxID(tx, mailboxID)
554 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
555 xcheckuserf(ctx, err, "getting mailbox")
557 xcheckf(ctx, err, "getting mailbox")
561// xmessageID returns a non-expunged message or panics with a sherpa error.
562func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
564 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
566 m := store.Message{ID: messageID}
568 if err == bstore.ErrAbsent {
569 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
570 } else if err == nil && m.Expunged {
571 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
573 xcheckf(ctx, err, "getting message")
577func xrandomID(ctx context.Context, n int) string {
578 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
581func xrandom(ctx context.Context, n int) []byte {
582 buf := make([]byte, n)
587// MessageSubmit sends a message by submitting it the outgoing email queue. The
588// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
589// Bcc message header.
591// If a Sent mailbox is configured, messages are added to it after submitting
592// to the delivery queue. If Bcc addresses were present, a header is prepended
593// to the message stored in the Sent mailbox.
594func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
595 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
596 acc := reqInfo.Account
599 log.Debug("message submit")
601 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
603 // 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.
605 // Prevent any accidental control characters, or attempts at getting bare \r or \n
607 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
608 for _, s := range l {
609 for _, c := range s {
611 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
617 fromAddr, err := parseAddress(m.From)
618 xcheckuserf(ctx, err, "parsing From address")
620 var replyTo *message.NameAddress
622 a, err := parseAddress(m.ReplyTo)
623 xcheckuserf(ctx, err, "parsing Reply-To address")
627 var recipients []smtp.Address
629 var toAddrs []message.NameAddress
630 for _, s := range m.To {
631 addr, err := parseAddress(s)
632 xcheckuserf(ctx, err, "parsing To address")
633 toAddrs = append(toAddrs, addr)
634 recipients = append(recipients, addr.Address)
637 var ccAddrs []message.NameAddress
638 for _, s := range m.Cc {
639 addr, err := parseAddress(s)
640 xcheckuserf(ctx, err, "parsing Cc address")
641 ccAddrs = append(ccAddrs, addr)
642 recipients = append(recipients, addr.Address)
645 var bccAddrs []message.NameAddress
646 for _, s := range m.Bcc {
647 addr, err := parseAddress(s)
648 xcheckuserf(ctx, err, "parsing Bcc address")
649 bccAddrs = append(bccAddrs, addr)
650 recipients = append(recipients, addr.Address)
653 // Check if from address is allowed for account.
654 if ok, disabled := mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address); disabled {
655 metricSubmission.WithLabelValues("domaindisabled").Inc()
656 xcheckuserf(ctx, mox.ErrDomainDisabled, `looking up "from" address for account`)
658 metricSubmission.WithLabelValues("badfrom").Inc()
659 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
662 if len(recipients) == 0 {
663 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
666 // Check outgoing message rate limit.
667 xdbread(ctx, acc, func(tx *bstore.Tx) {
668 rcpts := make([]smtp.Path, len(recipients))
669 for i, r := range recipients {
670 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
672 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
674 metricSubmission.WithLabelValues("messagelimiterror").Inc()
675 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
676 } else if rcptlimit >= 0 {
677 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
678 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
680 xcheckf(ctx, err, "checking send limit")
683 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
685 for _, a := range recipients {
686 if a.Localpart.IsInternational() {
691 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
692 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
695 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
699 // Create file to compose message into.
700 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
701 xcheckf(ctx, err, "creating temporary file for message")
702 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
704 // If writing to the message file fails, we abort immediately.
705 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
711 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
712 xcheckuserf(ctx, err, "making message")
713 } else if ok && errors.Is(err, message.ErrCompose) {
714 xcheckf(ctx, err, "making message")
719 // 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
721 // Each queued message gets a Received header.
722 // We don't have access to the local IP for adding.
723 // We cannot use VIA, because there is no registered method. We would like to use
724 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
725 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
726 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
727 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
728 recvHdrFor := func(rcptTo string) string {
729 recvHdr := &message.HeaderWriter{}
730 // For additional Received-header clauses, see:
731 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
732 // Note: we don't have "via" or "with", there is no registered for webmail.
733 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
734 if reqInfo.Request.TLS != nil {
735 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
737 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
738 return recvHdr.String()
741 // Outer message headers.
742 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
744 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
746 xc.HeaderAddrs("To", toAddrs)
747 xc.HeaderAddrs("Cc", ccAddrs)
748 // We prepend Bcc headers to the message when adding to the Sent mailbox.
750 xc.Subject(m.Subject)
753 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
754 xc.Header("Message-Id", messageID)
755 xc.Header("Date", time.Now().Format(message.RFC5322Z))
756 // Add In-Reply-To and References headers.
757 if m.ResponseMessageID > 0 {
758 xdbread(ctx, acc, func(tx *bstore.Tx) {
759 rm := xmessageID(ctx, tx, m.ResponseMessageID)
760 msgr := acc.MessageReader(rm)
763 log.Check(err, "closing message reader")
765 rp, err := rm.LoadPart(msgr)
766 xcheckf(ctx, err, "load parsed message")
767 h, err := rp.Header()
768 xcheckf(ctx, err, "parsing header")
770 if rp.Envelope == nil {
774 if rp.Envelope.MessageID != "" {
775 xc.Header("In-Reply-To", rp.Envelope.MessageID)
777 refs := h.Values("References")
778 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
779 refs = []string{rp.Envelope.InReplyTo}
781 if rp.Envelope.MessageID != "" {
782 refs = append(refs, rp.Envelope.MessageID)
785 xc.Header("References", strings.Join(refs, "\r\n\t"))
789 if m.UserAgent != "" {
790 xc.Header("User-Agent", m.UserAgent)
792 if m.RequireTLS != nil && !*m.RequireTLS {
793 xc.Header("TLS-Required", "No")
795 xc.Header("MIME-Version", "1.0")
797 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
798 mp := multipart.NewWriter(xc)
799 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
802 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
803 textHdr := textproto.MIMEHeader{}
804 textHdr.Set("Content-Type", ct)
805 textHdr.Set("Content-Transfer-Encoding", cte)
807 textp, err := mp.CreatePart(textHdr)
808 xcheckf(ctx, err, "adding text part to message")
809 _, err = textp.Write(textBody)
810 xcheckf(ctx, err, "writing text part")
812 xaddPart := func(ct, filename string) io.Writer {
813 ahdr := textproto.MIMEHeader{}
814 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
816 ahdr.Set("Content-Type", ct)
817 ahdr.Set("Content-Transfer-Encoding", "base64")
818 ahdr.Set("Content-Disposition", cd)
819 ap, err := mp.CreatePart(ahdr)
820 xcheckf(ctx, err, "adding attachment part to message")
824 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
825 ap := xaddPart(ct, filename)
827 for len(base64Data) > 0 {
830 line, base64Data = base64Data[:n], base64Data[n:]
831 _, err := ap.Write(line)
832 xcheckf(ctx, err, "writing attachment")
833 _, err = ap.Write([]byte("\r\n"))
834 xcheckf(ctx, err, "writing attachment")
838 xaddAttachment := func(ct, filename string, r io.Reader) {
839 ap := xaddPart(ct, filename)
840 wc := moxio.Base64Writer(ap)
841 _, err := io.Copy(wc, r)
842 xcheckf(ctx, err, "adding attachment")
844 xcheckf(ctx, err, "flushing attachment")
847 for _, a := range m.Attachments {
849 if !strings.HasPrefix(s, "data:") {
850 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
853 t := strings.SplitN(s, ",", 2)
855 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
857 if !strings.HasSuffix(t[0], "base64") {
858 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
860 ct := strings.TrimSuffix(t[0], "base64")
861 ct = strings.TrimSuffix(ct, ";")
863 ct = "application/octet-stream"
865 filename := a.Filename
867 filename = "unnamed.bin"
869 params := map[string]string{"name": filename}
870 ct = mime.FormatMediaType(ct, params)
872 // Ensure base64 is valid, then we'll write the original string.
873 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
874 xcheckuserf(ctx, err, "parsing attachment as base64")
876 xaddAttachmentBase64(ct, filename, []byte(t[1]))
879 if len(m.ForwardAttachments.Paths) > 0 {
880 acc.WithRLock(func() {
881 xdbread(ctx, acc, func(tx *bstore.Tx) {
882 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
883 msgr := acc.MessageReader(fm)
886 log.Check(err, "closing message reader")
889 fp, err := fm.LoadPart(msgr)
890 xcheckf(ctx, err, "load parsed message")
892 for _, path := range m.ForwardAttachments.Paths {
894 for _, xp := range path {
895 if xp < 0 || xp >= len(ap.Parts) {
896 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
901 _, filename, err := ap.DispositionFilename()
902 if err != nil && errors.Is(err, message.ErrParamEncoding) {
903 log.Debugx("parsing disposition/filename", err)
905 xcheckf(ctx, err, "reading disposition")
908 filename = "unnamed.bin"
910 params := map[string]string{"name": filename}
911 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
912 params["charset"] = pcharset
914 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
915 ct = mime.FormatMediaType(ct, params)
916 xaddAttachment(ct, filename, ap.Reader())
923 xcheckf(ctx, err, "writing mime multipart")
925 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
926 xc.Header("Content-Type", ct)
927 xc.Header("Content-Transfer-Encoding", cte)
929 xc.Write([]byte(textBody))
934 // Add DKIM-Signature headers.
936 fd := fromAddr.Address.Domain
937 confDom, _ := mox.Conf.Domain(fd)
938 if confDom.Disabled {
939 xcheckuserf(ctx, mox.ErrDomainDisabled, "checking domain")
941 selectors := mox.DKIMSelectors(confDom.DKIM)
942 if len(selectors) > 0 {
943 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
945 metricServerErrors.WithLabelValues("dkimsign").Inc()
947 xcheckf(ctx, err, "sign dkim")
949 msgPrefix = dkimHeaders
952 accConf, _ := acc.Conf()
953 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
954 xcheckf(ctx, err, "parsing login address")
955 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
956 fromPath := fromAddr.Address.Path()
957 var localpartBase string
959 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0]
961 qml := make([]queue.Msg, len(recipients))
963 for i, rcpt := range recipients {
967 fromID = xrandomID(ctx, 16)
968 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromID)
971 // Don't use per-recipient unique message prefix when multiple recipients are
972 // present, or the queue cannot deliver it in a single smtp transaction.
974 if len(recipients) == 1 {
975 recvRcpt = rcpt.Pack(smtputf8)
977 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
978 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
980 Localpart: rcpt.Localpart,
981 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
983 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
984 if m.FutureRelease != nil {
985 ival := time.Until(*m.FutureRelease)
987 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
988 } else if ival > queue.FutureReleaseIntervalMax {
989 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
991 qm.NextAttempt = *m.FutureRelease
992 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
993 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
996 // no qm.Extra from webmail
999 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
1001 metricSubmission.WithLabelValues("queueerror").Inc()
1003 xcheckf(ctx, err, "adding messages to the delivery queue")
1004 metricSubmission.WithLabelValues("ok").Inc()
1006 var modseq store.ModSeq // Only set if needed.
1008 // We have committed to sending the message. We want to follow through
1009 // with appending to Sent and removing the draft message.
1010 ctx = context.WithoutCancel(ctx)
1012 // Append message to Sent mailbox, mark original messages as answered/forwarded,
1013 // remove any draft message.
1014 acc.WithWLock(func() {
1015 var changes []store.Change
1019 if x := recover(); x != nil {
1021 metricServerErrors.WithLabelValues("submit").Inc()
1029 for _, id := range newIDs {
1030 p := acc.MessagePath(id)
1032 log.Check(err, "removing delivered message on error", slog.String("path", p))
1036 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1037 if m.DraftMessageID > 0 {
1038 nchanges := xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, &modseq)
1039 changes = append(changes, nchanges...)
1042 if m.ResponseMessageID > 0 {
1043 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1050 if !rm.Junk && !rm.Notjunk {
1053 if rm.Flags != oflags {
1055 modseq, err = acc.NextModSeq(tx)
1056 xcheckf(ctx, err, "next modseq")
1059 err := tx.Update(&rm)
1060 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1062 // Update modseq of mailbox of replied/forwarded message.
1063 rmb, err := store.MailboxID(tx, rm.MailboxID)
1064 xcheckf(ctx, err, "get mailbox of replied/forwarded message for modseq update")
1066 err = tx.Update(&rmb)
1067 xcheckf(ctx, err, "update modseq of mailbox of replied/forwarded message")
1069 changes = append(changes, rm.ChangeFlags(oflags, rmb))
1071 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm})
1072 xcheckf(ctx, err, "retraining messages after reply/forward")
1075 // Move messages from this thread still in this mailbox to the designated Archive
1077 if m.ArchiveThread {
1078 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Archive", true).Get()
1079 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1080 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1082 xcheckf(ctx, err, "looking up designated archive mailbox")
1085 q := bstore.QueryTx[store.Message](tx)
1086 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1087 q.FilterEqual("Expunged", false)
1088 err = q.IDs(&msgIDs)
1089 xcheckf(ctx, err, "listing messages in thread to archive")
1090 if len(msgIDs) > 0 {
1091 ids, nchanges := xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, &modseq)
1092 newIDs = append(newIDs, ids...)
1093 changes = append(changes, nchanges...)
1098 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1099 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1100 // There is no mailbox designated as Sent mailbox, so we're done.
1103 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1106 modseq, err = acc.NextModSeq(tx)
1107 xcheckf(ctx, err, "next modseq")
1110 // If there were bcc headers, prepend those to the stored message only, before the
1111 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1112 // message won't validate with DKIM anymore, which is fine.
1113 if len(bccAddrs) > 0 {
1114 var sb strings.Builder
1115 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1116 xbcc.HeaderAddrs("Bcc", bccAddrs)
1118 msgPrefix = sb.String() + msgPrefix
1121 sentm := store.Message{
1124 MailboxID: sentmb.ID,
1125 MailboxOrigID: sentmb.ID,
1126 Flags: store.Flags{Notjunk: true, Seen: true},
1127 Size: int64(len(msgPrefix)) + xc.Size,
1128 MsgPrefix: []byte(msgPrefix),
1131 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1132 if err != nil && errors.Is(err, store.ErrOverQuota) {
1133 xcheckuserf(ctx, err, "checking quota")
1134 } else if err != nil {
1135 metricSubmission.WithLabelValues("storesenterror").Inc()
1138 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1139 newIDs = append(newIDs, sentm.ID)
1141 err = tx.Update(&sentmb)
1142 xcheckf(ctx, err, "updating sent mailbox for counts")
1144 changes = append(changes, sentm.ChangeAddUID(sentmb), sentmb.ChangeCounts())
1148 store.BroadcastChanges(acc, changes)
1152// MessageMove moves messages to another mailbox. If the message is already in
1153// the mailbox an error is returned.
1154func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1155 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1156 acc := reqInfo.Account
1159 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1162var xops = webops.XOps{
1165 Checkuserf: xcheckuserf,
1168// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1169func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1170 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1171 acc := reqInfo.Account
1174 if len(messageIDs) == 0 {
1178 xops.MessageDelete(ctx, log, acc, messageIDs)
1181// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1182// flags should be lower-case, but will be converted and verified.
1183func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1184 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1185 acc := reqInfo.Account
1188 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1191// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1192func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1193 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1194 acc := reqInfo.Account
1197 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1200// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1201// not automatically included, they must explicitly be included in the list of IDs.
1202func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1203 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1204 acc := reqInfo.Account
1207 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1210// MailboxCreate creates a new mailbox.
1211func (Webmail) MailboxCreate(ctx context.Context, name string) {
1212 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1213 acc := reqInfo.Account
1216 name, _, err = store.CheckMailboxName(name, false)
1217 xcheckuserf(ctx, err, "checking mailbox name")
1219 acc.WithWLock(func() {
1220 var changes []store.Change
1221 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1224 _, changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1226 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1228 xcheckf(ctx, err, "creating mailbox")
1231 store.BroadcastChanges(acc, changes)
1235// MailboxDelete deletes a mailbox and all its messages and annotations.
1236func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1237 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1238 acc := reqInfo.Account
1241 acc.WithWLock(func() {
1242 var changes []store.Change
1244 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1245 mb := xmailboxID(ctx, tx, mailboxID)
1246 if mb.Name == "Inbox" {
1247 // Inbox is special in IMAP and cannot be removed.
1248 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1251 var hasChildren bool
1253 changes, hasChildren, err = acc.MailboxDelete(ctx, log, tx, &mb)
1255 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1257 xcheckf(ctx, err, "deleting mailbox")
1260 store.BroadcastChanges(acc, changes)
1264// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1265// its child mailboxes.
1266func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1267 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1268 acc := reqInfo.Account
1271 acc.WithWLock(func() {
1272 var changes []store.Change
1274 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1275 mb := xmailboxID(ctx, tx, mailboxID)
1277 qm := bstore.QueryTx[store.Message](tx)
1278 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1279 qm.FilterEqual("Expunged", false)
1282 xcheckf(ctx, err, "listing messages to remove")
1285 xcheckuserf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1288 modseq, err := acc.NextModSeq(tx)
1289 xcheckf(ctx, err, "next modseq")
1291 chrem, chmbcounts, err := acc.MessageRemove(log, tx, modseq, &mb, store.RemoveOpts{}, l...)
1292 xcheckf(ctx, err, "expunge messages")
1293 changes = append(changes, chrem, chmbcounts)
1295 err = tx.Update(&mb)
1296 xcheckf(ctx, err, "updating mailbox for counts")
1299 store.BroadcastChanges(acc, changes)
1303// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1304// ID and its messages are unchanged.
1305func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1306 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1307 acc := reqInfo.Account
1309 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1310 // standard. We can just say no.
1312 newName, _, err = store.CheckMailboxName(newName, false)
1313 xcheckuserf(ctx, err, "checking new mailbox name")
1315 acc.WithWLock(func() {
1316 var changes []store.Change
1318 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1319 mbsrc := xmailboxID(ctx, tx, mailboxID)
1321 var isInbox, alreadyExists bool
1322 var modseq store.ModSeq
1323 changes, isInbox, alreadyExists, err = acc.MailboxRename(tx, &mbsrc, newName, &modseq)
1324 if isInbox || alreadyExists {
1325 xcheckuserf(ctx, err, "renaming mailbox")
1327 xcheckf(ctx, err, "renaming mailbox")
1330 store.BroadcastChanges(acc, changes)
1334// CompleteRecipient returns autocomplete matches for a recipient, returning the
1335// matches, most recently used first, and whether this is the full list and further
1336// requests for longer prefixes aren't necessary.
1337func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1338 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1339 acc := reqInfo.Account
1341 search = strings.ToLower(search)
1343 var matches []string
1345 acc.WithRLock(func() {
1346 xdbread(ctx, acc, func(tx *bstore.Tx) {
1351 seen := map[key]bool{}
1353 q := bstore.QueryTx[store.Recipient](tx)
1355 err := q.ForEach(func(r store.Recipient) error {
1356 k := key{r.Localpart, r.Domain}
1360 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1361 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1362 if !strings.Contains(strings.ToLower(address), search) {
1365 if len(matches) >= 20 {
1367 return bstore.StopForEach
1370 // Look in the message that was sent for a name along with the address.
1371 m := store.Message{ID: r.MessageID}
1373 xcheckf(ctx, err, "get sent message")
1374 if !m.Expunged && m.ParsedBuf != nil {
1375 var part message.Part
1376 err := json.Unmarshal(m.ParsedBuf, &part)
1377 xcheckf(ctx, err, "parsing part")
1379 dom, err := dns.ParseDomain(r.Domain)
1380 xcheckf(ctx, err, "parsing domain of recipient")
1384 checkAddrs := func(l []message.Address) {
1388 for _, a := range l {
1389 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1391 address = addressString(a, false)
1396 if part.Envelope != nil {
1397 env := part.Envelope
1404 matches = append(matches, address)
1408 xcheckf(ctx, err, "listing recipients")
1414// addressString returns an address into a string as it could be used in a message header.
1415func addressString(a message.Address, smtputf8 bool) string {
1417 dom, err := dns.ParseDomain(a.Host)
1419 if smtputf8 && dom.Unicode != "" {
1426 return "<" + a.User + "@" + host + ">"
1429 const atom = "!#$%&'*+-/=?^_`{|}~"
1431 for _, c := range a.Name {
1432 if c == '\t' || c == ' ' || c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsAny(string(c), atom) {
1435 // We need to quote.
1437 for _, c := range a.Name {
1438 if c == '\\' || c == '"' {
1446 return name + " <" + a.User + "@" + host + ">"
1449// MailboxSetSpecialUse sets the special use flags of a mailbox.
1450func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1451 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1452 acc := reqInfo.Account
1454 acc.WithWLock(func() {
1455 var changes []store.Change
1457 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1458 xmb := xmailboxID(ctx, tx, mb.ID)
1460 modseq, err := acc.NextModSeq(tx)
1461 xcheckf(ctx, err, "get next modseq")
1463 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1464 // we set, we clear it for the mailbox(es) that had it, if any.
1465 clearPrevious := func(clear bool, specialUse string) {
1469 var ombl []store.Mailbox
1470 q := bstore.QueryTx[store.Mailbox](tx)
1471 q.FilterNotEqual("ID", mb.ID)
1472 q.FilterEqual(specialUse, true)
1474 _, err := q.UpdateFields(map[string]any{specialUse: false, "ModSeq": modseq})
1475 xcheckf(ctx, err, "updating previous special-use mailboxes")
1477 for _, omb := range ombl {
1478 changes = append(changes, omb.ChangeSpecialUse())
1481 clearPrevious(mb.Archive, "Archive")
1482 clearPrevious(mb.Draft, "Draft")
1483 clearPrevious(mb.Junk, "Junk")
1484 clearPrevious(mb.Sent, "Sent")
1485 clearPrevious(mb.Trash, "Trash")
1487 xmb.SpecialUse = mb.SpecialUse
1489 err = tx.Update(&xmb)
1490 xcheckf(ctx, err, "updating special-use flags for mailbox")
1491 changes = append(changes, xmb.ChangeSpecialUse())
1494 store.BroadcastChanges(acc, changes)
1498// ThreadCollapse saves the ThreadCollapse field for the messages and its
1499// children. The messageIDs are typically thread roots. But not all roots
1500// (without parent) of a thread need to have the same collapsed state.
1501func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1502 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1503 acc := reqInfo.Account
1505 if len(messageIDs) == 0 {
1506 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1509 acc.WithWLock(func() {
1510 changes := make([]store.Change, 0, len(messageIDs))
1511 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1512 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1513 // (child) messages. Further refined in FilterFn.
1514 threadIDs := map[int64]struct{}{}
1515 msgIDs := map[int64]struct{}{}
1516 for _, id := range messageIDs {
1517 m := store.Message{ID: id}
1519 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1520 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1522 xcheckf(ctx, err, "get message")
1523 threadIDs[m.ThreadID] = struct{}{}
1524 msgIDs[id] = struct{}{}
1527 var updated []store.Message
1528 q := bstore.QueryTx[store.Message](tx)
1529 q.FilterEqual("Expunged", false)
1530 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1531 q.FilterNotEqual("ThreadCollapsed", collapse)
1532 q.FilterFn(func(tm store.Message) bool {
1533 for _, id := range tm.ThreadParentIDs {
1534 if _, ok := msgIDs[id]; ok {
1538 _, ok := msgIDs[tm.ID]
1542 q.SortAsc("ID") // Consistent order for testing.
1543 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1544 xcheckf(ctx, err, "updating collapse in database")
1546 for _, m := range updated {
1547 changes = append(changes, m.ChangeThread())
1550 store.BroadcastChanges(acc, changes)
1554// ThreadMute saves the ThreadMute field for the messages and their children.
1555// If messages are muted, they are also marked collapsed.
1556func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1557 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1558 acc := reqInfo.Account
1560 if len(messageIDs) == 0 {
1561 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1564 acc.WithWLock(func() {
1565 changes := make([]store.Change, 0, len(messageIDs))
1566 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1567 threadIDs := map[int64]struct{}{}
1568 msgIDs := map[int64]struct{}{}
1569 for _, id := range messageIDs {
1570 m := store.Message{ID: id}
1572 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1573 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1575 xcheckf(ctx, err, "get message")
1576 threadIDs[m.ThreadID] = struct{}{}
1577 msgIDs[id] = struct{}{}
1580 var updated []store.Message
1582 q := bstore.QueryTx[store.Message](tx)
1583 q.FilterEqual("Expunged", false)
1584 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1585 q.FilterFn(func(tm store.Message) bool {
1586 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1589 for _, id := range tm.ThreadParentIDs {
1590 if _, ok := msgIDs[id]; ok {
1594 _, ok := msgIDs[tm.ID]
1598 fields := map[string]any{"ThreadMuted": mute}
1600 fields["ThreadCollapsed"] = true
1602 _, err := q.UpdateFields(fields)
1603 xcheckf(ctx, err, "updating mute in database")
1605 for _, m := range updated {
1606 changes = append(changes, m.ChangeThread())
1609 store.BroadcastChanges(acc, changes)
1613// SecurityResult indicates whether a security feature is supported.
1614type SecurityResult string
1617 SecurityResultError SecurityResult = "error"
1618 SecurityResultNo SecurityResult = "no"
1619 SecurityResultYes SecurityResult = "yes"
1620 // Unknown whether supported. Finding out may only be (reasonably) possible when
1621 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1623 SecurityResultUnknown SecurityResult = "unknown"
1626// RecipientSecurity is a quick analysis of the security properties of delivery to
1627// the recipient (domain).
1628type RecipientSecurity struct {
1629 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1630 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1632 STARTTLS SecurityResult
1634 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1636 MTASTS SecurityResult
1638 // Whether MX lookup response was DNSSEC-signed.
1639 DNSSEC SecurityResult
1641 // Whether first delivery destination has DANE records.
1644 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1645 // Will be "unknown" if no delivery to the domain has been attempted yet.
1646 RequireTLS SecurityResult
1649// RecipientSecurity looks up security properties of the address in the
1650// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1651func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1652 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1655 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1656 return recipientSecurity(ctx, log, resolver, messageAddressee)
1659// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1660func logPanic(ctx context.Context) {
1665 log := pkglog.WithContext(ctx)
1666 log.Error("recover from panic", slog.Any("panic", x))
1668 metrics.PanicInc(metrics.Webmail)
1671// separate function for testing with mocked resolver.
1672func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1673 rs := RecipientSecurity{
1674 SecurityResultUnknown,
1675 SecurityResultUnknown,
1676 SecurityResultUnknown,
1677 SecurityResultUnknown,
1678 SecurityResultUnknown,
1681 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1682 msgAddr, err := parser.Parse(messageAddressee)
1684 return rs, fmt.Errorf("parsing addressee: %v", err)
1686 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1688 return rs, fmt.Errorf("parsing address: %v", err)
1691 var wg sync.WaitGroup
1699 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1700 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1701 rs.MTASTS = SecurityResultYes
1702 } else if err == nil {
1703 rs.MTASTS = SecurityResultNo
1705 rs.MTASTS = SecurityResultError
1715 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hostPrefs, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1717 rs.DNSSEC = SecurityResultError
1720 if origNextHopAuthentic && expandedNextHopAuthentic {
1721 rs.DNSSEC = SecurityResultYes
1723 rs.DNSSEC = SecurityResultNo
1726 if !origNextHopAuthentic {
1727 rs.DANE = SecurityResultNo
1731 // We're only looking at the first host to deliver to (typically first mx destination).
1732 if len(hostPrefs) == 0 || hostPrefs[0].Host.Domain.IsZero() {
1733 return // Should not happen.
1735 host := hostPrefs[0].Host
1737 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1738 // error result instead of no-DANE result.
1739 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1741 rs.DANE = SecurityResultError
1745 rs.DANE = SecurityResultNo
1749 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1751 rs.DANE = SecurityResultError
1753 } else if daneRequired {
1754 rs.DANE = SecurityResultYes
1756 rs.DANE = SecurityResultNo
1760 // STARTTLS and RequireTLS
1761 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1762 acc := reqInfo.Account
1764 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1765 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1766 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1768 if err == bstore.ErrAbsent {
1770 } else if err != nil {
1771 rs.STARTTLS = SecurityResultError
1772 rs.RequireTLS = SecurityResultError
1773 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1777 rs.STARTTLS = SecurityResultYes
1779 rs.STARTTLS = SecurityResultNo
1782 rs.RequireTLS = SecurityResultYes
1784 rs.RequireTLS = SecurityResultNo
1788 xcheckf(ctx, err, "lookup recipient domain")
1795// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1796func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1797 s, err := wordDecoder.DecodeHeader(text)
1798 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1802// SettingsSave saves settings, e.g. for composing.
1803func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1804 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1805 acc := reqInfo.Account
1808 err := acc.DB.Update(ctx, &settings)
1809 xcheckf(ctx, err, "save settings")
1812func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1813 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1814 acc := reqInfo.Account
1817 xdbread(ctx, acc, func(tx *bstore.Tx) {
1818 m := xmessageID(ctx, tx, msgID)
1819 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1820 mbDst := xmailboxID(ctx, tx, mbDstID)
1822 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1825 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1827 conf, _ := acc.Conf()
1828 dest := conf.Destinations[rcptTo] // May not be present.
1829 defaultMailbox := "Inbox"
1830 if dest.Mailbox != "" {
1831 defaultMailbox = dest.Mailbox
1834 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1835 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1839 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1840 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1841 xcheckf(ctx, err, "looking up previous response for source mailbox")
1845 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1846 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1851 // Parse message for List-Id header.
1852 state := msgState{acc: acc}
1854 pm, err := parsedMessage(log, &m, &state, true, false, false)
1855 xcheckf(ctx, err, "parsing message")
1857 // The suggested ruleset. Once all is checked, we'll return it.
1858 var nrs *config.Ruleset
1860 // If List-Id header is present, we'll treat it as a (mailing) list message.
1861 if l, ok := pm.Headers["List-Id"]; ok {
1863 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1866 var listIDDom dns.Domain
1867 listID, listIDDom = parseListID(l[0])
1869 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1873 // Check if we have a previous "No" answer for this list-id.
1874 no := store.RulesetNoListID{
1875 RcptToAddress: rcptTo,
1877 ToInbox: mbDst.Name == "Inbox",
1879 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1880 xcheckf(ctx, err, "looking up previous response for list-id")
1885 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1886 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1887 // are subscribed to, and take advantage of any reduced junk filtering.
1888 listIDDomStr := listIDDom.Name()
1890 doms := m.DKIMDomains
1891 if m.MailFromValidated {
1892 doms = append(doms, m.MailFromDomain)
1894 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1895 // of SPF verification of one host.
1896 sort.Slice(doms, func(i, j int) bool {
1897 return len(doms[i]) < len(doms[j])
1899 var listAllowDom string
1900 for _, dom := range doms {
1901 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1906 if listAllowDom == "" {
1910 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1911 nrs = &config.Ruleset{
1912 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1913 ListAllowDomain: listAllowDom,
1914 Mailbox: mbDst.Name,
1917 // Otherwise, try to make a rule based on message "From" address.
1918 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1921 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1923 no := store.RulesetNoMsgFrom{
1924 RcptToAddress: rcptTo,
1925 MsgFromAddress: msgFrom,
1926 ToInbox: mbDst.Name == "Inbox",
1928 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1929 xcheckf(ctx, err, "looking up previous response for message from address")
1934 nrs = &config.Ruleset{
1935 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1936 Mailbox: mbDst.Name,
1940 // Only suggest adding/removing rule if it isn't/is present.
1942 for _, rs := range dest.Rulesets {
1943 xrs := config.Ruleset{
1944 MsgFromRegexp: rs.MsgFromRegexp,
1945 HeadersRegexp: rs.HeadersRegexp,
1946 ListAllowDomain: rs.ListAllowDomain,
1947 Mailbox: nrs.Mailbox,
1949 if xrs.Equal(*nrs) {
1954 isRemove = mbDst.Name == defaultMailbox
1956 nrs.Mailbox = mbSrc.Name
1958 if isRemove && !have || !isRemove && have {
1962 // We'll be returning a suggested ruleset.
1963 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1969// Parse the list-id value (the value between <>) from a list-id header.
1970// Returns an empty string if it couldn't be parsed.
1971func parseListID(s string) (listID string, dom dns.Domain) {
1973 s = strings.TrimRight(s, " \t")
1974 if !strings.HasSuffix(s, ">") {
1975 return "", dns.Domain{}
1978 t := strings.Split(s, "<")
1980 return "", dns.Domain{}
1983 dom, err := dns.ParseDomain(s)
1990func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1991 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1993 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1994 dest, ok := acc.Destinations[rcptTo]
1996 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1997 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")
2000 nd := map[string]config.Destination{}
2001 maps.Copy(nd, acc.Destinations)
2002 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2004 acc.Destinations = nd
2006 xcheckf(ctx, err, "saving account with new ruleset")
2009func (Webmail) RulesetRemove(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 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2018 nd := map[string]config.Destination{}
2019 maps.Copy(nd, acc.Destinations)
2020 var l []config.Ruleset
2022 for _, rs := range dest.Rulesets {
2023 if rs.Equal(ruleset) {
2030 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2034 acc.Destinations = nd
2036 xcheckf(ctx, err, "saving account with new ruleset")
2039func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2040 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2041 acc := reqInfo.Account
2045 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2047 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2049 xcheckf(ctx, err, "storing user response")
2052func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2053 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2054 acc := reqInfo.Account
2056 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2057 xcheckf(ctx, err, "storing user response")
2060func slicesAny[T any](l []T) []any {
2061 r := make([]any, len(l))
2062 for i, v := range l {
2068// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2069func (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) {