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/config"
 
37	"github.com/mjl-/mox/dkim"
 
38	"github.com/mjl-/mox/dns"
 
39	"github.com/mjl-/mox/message"
 
40	"github.com/mjl-/mox/metrics"
 
41	"github.com/mjl-/mox/mlog"
 
42	"github.com/mjl-/mox/mox-"
 
43	"github.com/mjl-/mox/moxio"
 
44	"github.com/mjl-/mox/moxvar"
 
45	"github.com/mjl-/mox/mtasts"
 
46	"github.com/mjl-/mox/mtastsdb"
 
47	"github.com/mjl-/mox/queue"
 
48	"github.com/mjl-/mox/smtp"
 
49	"github.com/mjl-/mox/smtpclient"
 
50	"github.com/mjl-/mox/store"
 
51	"github.com/mjl-/mox/webauth"
 
52	"github.com/mjl-/mox/webops"
 
56var webmailapiJSON []byte
 
59	maxMessageSize int64  // From listener.
 
60	cookiePath     string // From listener.
 
61	isForwarded    bool   // From listener, whether we look at X-Forwarded-* headers.
 
64func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
 
65	err := json.Unmarshal(buf, &doc)
 
67		pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
 
72var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
 
74var sherpaHandlerOpts *sherpa.HandlerOpts
 
76func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
 
77	return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
 
81	collector, err := sherpaprom.NewCollector("moxwebmail", nil)
 
83		pkglog.Fatalx("creating sherpa prometheus collector", err)
 
86	sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
 
88	_, err = makeSherpaHandler(0, "", false)
 
90		pkglog.Fatalx("sherpa handler", err)
 
94// LoginPrep returns a login token, and also sets it as cookie. Both must be
 
95// present in the call to Login.
 
96func (w Webmail) LoginPrep(ctx context.Context) string {
 
97	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
101	_, err := cryptorand.Read(data[:])
 
102	xcheckf(ctx, err, "generate token")
 
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// Token returns a token to use for an SSE connection. A token can only be used for
 
134// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
 
135// with at most 10 unused tokens (the most recently created) per account.
 
136func (Webmail) Token(ctx context.Context) string {
 
137	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
138	return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
 
141// Requests sends a new request for an open SSE connection. Any currently active
 
142// request for the connection will be canceled, but this is done asynchrously, so
 
143// the SSE connection may still send results for the previous request. Callers
 
144// should take care to ignore such results. If req.Cancel is set, no new request is
 
146func (Webmail) Request(ctx context.Context, req Request) {
 
147	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
149	if !req.Cancel && req.Page.Count <= 0 {
 
150		xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
 
153	sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
 
155		xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
 
160// ParsedMessage returns enough to render the textual body of a message. It is
 
161// assumed the client already has other fields through MessageItem.
 
162func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
 
163	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
165	acc := reqInfo.Account
 
167	xdbread(ctx, acc, func(tx *bstore.Tx) {
 
168		m := xmessageID(ctx, tx, msgID)
 
170		state := msgState{acc: acc}
 
173		pm, err = parsedMessage(log, m, &state, true, false)
 
174		xcheckf(ctx, err, "parsing message")
 
176		if len(pm.envelope.From) == 1 {
 
177			pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
 
178			xcheckf(ctx, err, "looking up view mode for from address")
 
184// fromAddrViewMode returns the view mode for a from address.
 
185func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
 
186	lp, err := smtp.ParseLocalpart(from.User)
 
188		return store.ModeDefault, nil
 
190	fromAddr := smtp.Address{Localpart: lp, Domain: from.Domain}.Pack(true)
 
191	fas := store.FromAddressSettings{FromAddress: fromAddr}
 
193	if err == bstore.ErrAbsent {
 
194		return store.ModeDefault, nil
 
196	return fas.ViewMode, err
 
199// FromAddressSettingsSave saves per-"From"-address settings.
 
200func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
 
201	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
202	acc := reqInfo.Account
 
204	if fas.FromAddress == "" {
 
205		xcheckuserf(ctx, errors.New("empty from address"), "checking address")
 
208	xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
209		if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
 
210			err := tx.Update(&fas)
 
211			xcheckf(ctx, err, "updating settings for from address")
 
213			err := tx.Insert(&fas)
 
214			xcheckf(ctx, err, "inserting settings for from address")
 
219// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
 
220// of the message in storage. Used when opening a previously saved draft message
 
222// If no message is find, zero is returned, not an error.
 
223func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
 
224	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
225	acc := reqInfo.Account
 
227	messageID, _, _ = message.MessageIDCanonical(messageID)
 
229		xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
 
232	xdbread(ctx, acc, func(tx *bstore.Tx) {
 
233		m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
 
234		if err == bstore.ErrAbsent {
 
237		xcheckf(ctx, err, "looking up message by message-id")
 
243// ComposeMessage is a message to be composed, for saving draft messages.
 
244type ComposeMessage struct {
 
249	ReplyTo           string // If non-empty, Reply-To header to add to message.
 
252	ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
 
253	DraftMessageID    int64 // If set, previous draft message that will be removed after composing new message.
 
256// MessageCompose composes a message and saves it to the mailbox. Used for
 
257// saving draft messages.
 
258func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
 
259	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
260	acc := reqInfo.Account
 
263	log.Debug("message compose")
 
265	// Prevent any accidental control characters, or attempts at getting bare \r or \n
 
267	for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
 
268		for _, s := range l {
 
269			for _, c := range s {
 
271					xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
 
277	fromAddr, err := parseAddress(m.From)
 
278	xcheckuserf(ctx, err, "parsing From address")
 
280	var replyTo *message.NameAddress
 
282		addr, err := parseAddress(m.ReplyTo)
 
283		xcheckuserf(ctx, err, "parsing Reply-To address")
 
287	var recipients []smtp.Address
 
289	var toAddrs []message.NameAddress
 
290	for _, s := range m.To {
 
291		addr, err := parseAddress(s)
 
292		xcheckuserf(ctx, err, "parsing To address")
 
293		toAddrs = append(toAddrs, addr)
 
294		recipients = append(recipients, addr.Address)
 
297	var ccAddrs []message.NameAddress
 
298	for _, s := range m.Cc {
 
299		addr, err := parseAddress(s)
 
300		xcheckuserf(ctx, err, "parsing Cc address")
 
301		ccAddrs = append(ccAddrs, addr)
 
302		recipients = append(recipients, addr.Address)
 
305	var bccAddrs []message.NameAddress
 
306	for _, s := range m.Bcc {
 
307		addr, err := parseAddress(s)
 
308		xcheckuserf(ctx, err, "parsing Bcc address")
 
309		bccAddrs = append(bccAddrs, addr)
 
310		recipients = append(recipients, addr.Address)
 
313	// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
 
315	for _, a := range recipients {
 
316		if a.Localpart.IsInternational() {
 
321	if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
 
322		// todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
 
325	if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
 
329	// Create file to compose message into.
 
330	dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
 
331	xcheckf(ctx, err, "creating temporary file for compose message")
 
332	defer store.CloseRemoveTempFile(log, dataFile, "compose message")
 
334	// If writing to the message file fails, we abort immediately.
 
335	xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
 
341		if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
 
342			xcheckuserf(ctx, err, "making message")
 
343		} else if ok && errors.Is(err, message.ErrCompose) {
 
344			xcheckf(ctx, err, "making message")
 
349	// Outer message headers.
 
350	xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
 
352		xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
 
354	xc.HeaderAddrs("To", toAddrs)
 
355	xc.HeaderAddrs("Cc", ccAddrs)
 
356	xc.HeaderAddrs("Bcc", bccAddrs)
 
358		xc.Subject(m.Subject)
 
361	// Add In-Reply-To and References headers.
 
362	if m.ResponseMessageID > 0 {
 
363		xdbread(ctx, acc, func(tx *bstore.Tx) {
 
364			rm := xmessageID(ctx, tx, m.ResponseMessageID)
 
365			msgr := acc.MessageReader(rm)
 
368				log.Check(err, "closing message reader")
 
370			rp, err := rm.LoadPart(msgr)
 
371			xcheckf(ctx, err, "load parsed message")
 
372			h, err := rp.Header()
 
373			xcheckf(ctx, err, "parsing header")
 
375			if rp.Envelope == nil {
 
379			if rp.Envelope.MessageID != "" {
 
380				xc.Header("In-Reply-To", rp.Envelope.MessageID)
 
382			refs := h.Values("References")
 
383			if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
 
384				refs = []string{rp.Envelope.InReplyTo}
 
386			if rp.Envelope.MessageID != "" {
 
387				refs = append(refs, rp.Envelope.MessageID)
 
390				xc.Header("References", strings.Join(refs, "\r\n\t"))
 
394	xc.Header("MIME-Version", "1.0")
 
395	textBody, ct, cte := xc.TextPart("plain", m.TextBody)
 
396	xc.Header("Content-Type", ct)
 
397	xc.Header("Content-Transfer-Encoding", cte)
 
399	xc.Write([]byte(textBody))
 
404	// Remove previous draft message, append message to destination mailbox.
 
405	acc.WithRLock(func() {
 
406		var changes []store.Change
 
408		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
409			var modseq store.ModSeq // Only set if needed.
 
411			if m.DraftMessageID > 0 {
 
412				var nchanges []store.Change
 
413				modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
 
414				changes = append(changes, nchanges...)
 
415				// On-disk file is removed after lock.
 
418			// Find mailbox to write to.
 
419			mb := store.Mailbox{ID: mailboxID}
 
421			if err == bstore.ErrAbsent {
 
422				xcheckuserf(ctx, err, "looking up mailbox")
 
424			xcheckf(ctx, err, "looking up mailbox")
 
427				modseq, err = acc.NextModSeq(tx)
 
428				xcheckf(ctx, err, "next modseq")
 
435				MailboxOrigID: mb.ID,
 
436				Flags:         store.Flags{Notjunk: true},
 
440			if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
 
441				xcheckf(ctx, err, "checking quota")
 
443				xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
 
446			// Update mailbox before delivery, which changes uidnext.
 
447			mb.Add(nm.MailboxCounts())
 
449			xcheckf(ctx, err, "updating sent mailbox for counts")
 
451			err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
 
452			xcheckf(ctx, err, "storing message in mailbox")
 
454			changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
 
457		store.BroadcastChanges(acc, changes)
 
460	// Remove on-disk file for removed draft message.
 
461	if m.DraftMessageID > 0 {
 
462		p := acc.MessagePath(m.DraftMessageID)
 
464		log.Check(err, "removing draft message file")
 
470// Attachment is a MIME part is an existing message that is not intended as
 
471// viewable text or HTML part.
 
472type Attachment struct {
 
473	Path []int // Indices into top-level message.Part.Parts.
 
475	// File name based on "name" attribute of "Content-Type", or the "filename"
 
476	// attribute of "Content-Disposition".
 
482// SubmitMessage is an email message to be sent to one or more recipients.
 
483// Addresses are formatted as just email address, or with a name like "name
 
485type SubmitMessage struct {
 
490	ReplyTo            string // If non-empty, Reply-To header to add to message.
 
494	ForwardAttachments ForwardAttachments
 
496	ResponseMessageID  int64      // If set, this was a reply or forward, based on IsForward.
 
497	UserAgent          string     // User-Agent header added if not empty.
 
498	RequireTLS         *bool      // For "Require TLS" extension during delivery.
 
499	FutureRelease      *time.Time // If set, time (in the future) when message should be delivered from queue.
 
500	ArchiveThread      bool       // If set, thread is archived after sending message.
 
501	DraftMessageID     int64      // If set, draft message that will be removed after sending.
 
504// ForwardAttachments references attachments by a list of message.Part paths.
 
505type ForwardAttachments struct {
 
506	MessageID int64   // Only relevant if MessageID is not 0.
 
507	Paths     [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
 
510// File is a new attachment (not from an existing message that is being
 
511// forwarded) to send with a SubmitMessage.
 
514	DataURI  string // Full data of the attachment, with base64 encoding and including content-type.
 
517// parseAddress expects either a plain email address like "user@domain", or a
 
518// single address as used in a message header, like "name <user@domain>".
 
519func parseAddress(msghdr string) (message.NameAddress, error) {
 
520	a, err := mail.ParseAddress(msghdr)
 
522		return message.NameAddress{}, err
 
526	path, err := smtp.ParseAddress(a.Address)
 
528		return message.NameAddress{}, err
 
530	return message.NameAddress{DisplayName: a.Name, Address: path}, nil
 
533func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
 
535		xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
 
537	mb := store.Mailbox{ID: mailboxID}
 
539	if err == bstore.ErrAbsent {
 
540		xcheckuserf(ctx, err, "getting mailbox")
 
542	xcheckf(ctx, err, "getting mailbox")
 
546// xmessageID returns a non-expunged message or panics with a sherpa error.
 
547func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
 
549		xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
 
551	m := store.Message{ID: messageID}
 
553	if err == bstore.ErrAbsent {
 
554		xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
 
555	} else if err == nil && m.Expunged {
 
556		xcheckuserf(ctx, errors.New("message was removed"), "getting message")
 
558	xcheckf(ctx, err, "getting message")
 
562func xrandomID(ctx context.Context, n int) string {
 
563	return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
 
566func xrandom(ctx context.Context, n int) []byte {
 
567	buf := make([]byte, n)
 
568	x, err := cryptorand.Read(buf)
 
569	xcheckf(ctx, err, "read random")
 
571		xcheckf(ctx, errors.New("short random read"), "read random")
 
576// MessageSubmit sends a message by submitting it the outgoing email queue. The
 
577// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
 
578// Bcc message header.
 
580// If a Sent mailbox is configured, messages are added to it after submitting
 
581// to the delivery queue. If Bcc addresses were present, a header is prepended
 
582// to the message stored in the Sent mailbox.
 
583func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
 
584	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
585	acc := reqInfo.Account
 
588	log.Debug("message submit")
 
590	// Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
 
592	// 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.
 
594	// Prevent any accidental control characters, or attempts at getting bare \r or \n
 
596	for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
 
597		for _, s := range l {
 
598			for _, c := range s {
 
600					xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
 
606	fromAddr, err := parseAddress(m.From)
 
607	xcheckuserf(ctx, err, "parsing From address")
 
609	var replyTo *message.NameAddress
 
611		a, err := parseAddress(m.ReplyTo)
 
612		xcheckuserf(ctx, err, "parsing Reply-To address")
 
616	var recipients []smtp.Address
 
618	var toAddrs []message.NameAddress
 
619	for _, s := range m.To {
 
620		addr, err := parseAddress(s)
 
621		xcheckuserf(ctx, err, "parsing To address")
 
622		toAddrs = append(toAddrs, addr)
 
623		recipients = append(recipients, addr.Address)
 
626	var ccAddrs []message.NameAddress
 
627	for _, s := range m.Cc {
 
628		addr, err := parseAddress(s)
 
629		xcheckuserf(ctx, err, "parsing Cc address")
 
630		ccAddrs = append(ccAddrs, addr)
 
631		recipients = append(recipients, addr.Address)
 
634	var bccAddrs []message.NameAddress
 
635	for _, s := range m.Bcc {
 
636		addr, err := parseAddress(s)
 
637		xcheckuserf(ctx, err, "parsing Bcc address")
 
638		bccAddrs = append(bccAddrs, addr)
 
639		recipients = append(recipients, addr.Address)
 
642	// Check if from address is allowed for account.
 
643	if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
 
644		metricSubmission.WithLabelValues("badfrom").Inc()
 
645		xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
 
648	if len(recipients) == 0 {
 
649		xcheckuserf(ctx, errors.New("no recipients"), "composing message")
 
652	// Check outgoing message rate limit.
 
653	xdbread(ctx, acc, func(tx *bstore.Tx) {
 
654		rcpts := make([]smtp.Path, len(recipients))
 
655		for i, r := range recipients {
 
656			rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
 
658		msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
 
660			metricSubmission.WithLabelValues("messagelimiterror").Inc()
 
661			xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
 
662		} else if rcptlimit >= 0 {
 
663			metricSubmission.WithLabelValues("recipientlimiterror").Inc()
 
664			xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
 
666		xcheckf(ctx, err, "checking send limit")
 
669	// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
 
671	for _, a := range recipients {
 
672		if a.Localpart.IsInternational() {
 
677	if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
 
678		// todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
 
681	if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
 
685	// Create file to compose message into.
 
686	dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
 
687	xcheckf(ctx, err, "creating temporary file for message")
 
688	defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
 
690	// If writing to the message file fails, we abort immediately.
 
691	xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
 
697		if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
 
698			xcheckuserf(ctx, err, "making message")
 
699		} else if ok && errors.Is(err, message.ErrCompose) {
 
700			xcheckf(ctx, err, "making message")
 
705	// 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
 
707	// Each queued message gets a Received header.
 
708	// We don't have access to the local IP for adding.
 
709	// We cannot use VIA, because there is no registered method. We would like to use
 
710	// it to add the ascii domain name in case of smtputf8 and IDNA host name.
 
711	recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
 
712	recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
 
713	recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
 
714	recvHdrFor := func(rcptTo string) string {
 
715		recvHdr := &message.HeaderWriter{}
 
716		// For additional Received-header clauses, see:
 
717		// https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
 
718		// Note: we don't have "via" or "with", there is no registered for webmail.
 
719		recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) // 
../rfc/5321:3158 
720		if reqInfo.Request.TLS != nil {
 
721			recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
 
723		recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
 
724		return recvHdr.String()
 
727	// Outer message headers.
 
728	xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
 
730		xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
 
732	xc.HeaderAddrs("To", toAddrs)
 
733	xc.HeaderAddrs("Cc", ccAddrs)
 
734	// We prepend Bcc headers to the message when adding to the Sent mailbox.
 
736		xc.Subject(m.Subject)
 
739	messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
 
740	xc.Header("Message-Id", messageID)
 
741	xc.Header("Date", time.Now().Format(message.RFC5322Z))
 
742	// Add In-Reply-To and References headers.
 
743	if m.ResponseMessageID > 0 {
 
744		xdbread(ctx, acc, func(tx *bstore.Tx) {
 
745			rm := xmessageID(ctx, tx, m.ResponseMessageID)
 
746			msgr := acc.MessageReader(rm)
 
749				log.Check(err, "closing message reader")
 
751			rp, err := rm.LoadPart(msgr)
 
752			xcheckf(ctx, err, "load parsed message")
 
753			h, err := rp.Header()
 
754			xcheckf(ctx, err, "parsing header")
 
756			if rp.Envelope == nil {
 
760			if rp.Envelope.MessageID != "" {
 
761				xc.Header("In-Reply-To", rp.Envelope.MessageID)
 
763			refs := h.Values("References")
 
764			if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
 
765				refs = []string{rp.Envelope.InReplyTo}
 
767			if rp.Envelope.MessageID != "" {
 
768				refs = append(refs, rp.Envelope.MessageID)
 
771				xc.Header("References", strings.Join(refs, "\r\n\t"))
 
775	if m.UserAgent != "" {
 
776		xc.Header("User-Agent", m.UserAgent)
 
778	if m.RequireTLS != nil && !*m.RequireTLS {
 
779		xc.Header("TLS-Required", "No")
 
781	xc.Header("MIME-Version", "1.0")
 
783	if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
 
784		mp := multipart.NewWriter(xc)
 
785		xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
 
788		textBody, ct, cte := xc.TextPart("plain", m.TextBody)
 
789		textHdr := textproto.MIMEHeader{}
 
790		textHdr.Set("Content-Type", ct)
 
791		textHdr.Set("Content-Transfer-Encoding", cte)
 
793		textp, err := mp.CreatePart(textHdr)
 
794		xcheckf(ctx, err, "adding text part to message")
 
795		_, err = textp.Write(textBody)
 
796		xcheckf(ctx, err, "writing text part")
 
798		xaddPart := func(ct, filename string) io.Writer {
 
799			ahdr := textproto.MIMEHeader{}
 
800			cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
 
802			ahdr.Set("Content-Type", ct)
 
803			ahdr.Set("Content-Transfer-Encoding", "base64")
 
804			ahdr.Set("Content-Disposition", cd)
 
805			ap, err := mp.CreatePart(ahdr)
 
806			xcheckf(ctx, err, "adding attachment part to message")
 
810		xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
 
811			ap := xaddPart(ct, filename)
 
813			for len(base64Data) > 0 {
 
819				line, base64Data = base64Data[:n], base64Data[n:]
 
820				_, err := ap.Write(line)
 
821				xcheckf(ctx, err, "writing attachment")
 
822				_, err = ap.Write([]byte("\r\n"))
 
823				xcheckf(ctx, err, "writing attachment")
 
827		xaddAttachment := func(ct, filename string, r io.Reader) {
 
828			ap := xaddPart(ct, filename)
 
829			wc := moxio.Base64Writer(ap)
 
830			_, err := io.Copy(wc, r)
 
831			xcheckf(ctx, err, "adding attachment")
 
833			xcheckf(ctx, err, "flushing attachment")
 
836		for _, a := range m.Attachments {
 
838			if !strings.HasPrefix(s, "data:") {
 
839				xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
 
842			t := strings.SplitN(s, ",", 2)
 
844				xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
 
846			if !strings.HasSuffix(t[0], "base64") {
 
847				xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
 
849			ct := strings.TrimSuffix(t[0], "base64")
 
850			ct = strings.TrimSuffix(ct, ";")
 
852				ct = "application/octet-stream"
 
854			filename := a.Filename
 
856				filename = "unnamed.bin"
 
858			params := map[string]string{"name": filename}
 
859			ct = mime.FormatMediaType(ct, params)
 
861			// Ensure base64 is valid, then we'll write the original string.
 
862			_, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
 
863			xcheckuserf(ctx, err, "parsing attachment as base64")
 
865			xaddAttachmentBase64(ct, filename, []byte(t[1]))
 
868		if len(m.ForwardAttachments.Paths) > 0 {
 
869			acc.WithRLock(func() {
 
870				xdbread(ctx, acc, func(tx *bstore.Tx) {
 
871					fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
 
872					msgr := acc.MessageReader(fm)
 
875						log.Check(err, "closing message reader")
 
878					fp, err := fm.LoadPart(msgr)
 
879					xcheckf(ctx, err, "load parsed message")
 
881					for _, path := range m.ForwardAttachments.Paths {
 
883						for _, xp := range path {
 
884							if xp < 0 || xp >= len(ap.Parts) {
 
885								xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
 
890						filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
 
892							filename = "unnamed.bin"
 
894						params := map[string]string{"name": filename}
 
895						if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
 
896							params["charset"] = pcharset
 
898						ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
 
899						ct = mime.FormatMediaType(ct, params)
 
900						xaddAttachment(ct, filename, ap.Reader())
 
907		xcheckf(ctx, err, "writing mime multipart")
 
909		textBody, ct, cte := xc.TextPart("plain", m.TextBody)
 
910		xc.Header("Content-Type", ct)
 
911		xc.Header("Content-Transfer-Encoding", cte)
 
913		xc.Write([]byte(textBody))
 
918	// Add DKIM-Signature headers.
 
920	fd := fromAddr.Address.Domain
 
921	confDom, _ := mox.Conf.Domain(fd)
 
922	selectors := mox.DKIMSelectors(confDom.DKIM)
 
923	if len(selectors) > 0 {
 
924		dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
 
926			metricServerErrors.WithLabelValues("dkimsign").Inc()
 
928		xcheckf(ctx, err, "sign dkim")
 
930		msgPrefix = dkimHeaders
 
933	accConf, _ := acc.Conf()
 
934	loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
 
935	xcheckf(ctx, err, "parsing login address")
 
936	useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
 
937	fromPath := fromAddr.Address.Path()
 
938	var localpartBase string
 
940		localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
 
942	qml := make([]queue.Msg, len(recipients))
 
944	for i, rcpt := range recipients {
 
948			fromID = xrandomID(ctx, 16)
 
949			fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
 
952		// Don't use per-recipient unique message prefix when multiple recipients are
 
953		// present, or the queue cannot deliver it in a single smtp transaction.
 
955		if len(recipients) == 1 {
 
956			recvRcpt = rcpt.Pack(smtputf8)
 
958		rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
 
959		msgSize := int64(len(rcptMsgPrefix)) + xc.Size
 
961			Localpart: rcpt.Localpart,
 
962			IPDomain:  dns.IPDomain{Domain: rcpt.Domain},
 
964		qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
 
965		if m.FutureRelease != nil {
 
966			ival := time.Until(*m.FutureRelease)
 
968				xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
 
969			} else if ival > queue.FutureReleaseIntervalMax {
 
970				xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
 
972			qm.NextAttempt = *m.FutureRelease
 
973			qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
 
974			// todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
 
977		// no qm.Extra from webmail
 
980	err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
 
982		metricSubmission.WithLabelValues("queueerror").Inc()
 
984	xcheckf(ctx, err, "adding messages to the delivery queue")
 
985	metricSubmission.WithLabelValues("ok").Inc()
 
987	var modseq store.ModSeq // Only set if needed.
 
989	// Append message to Sent mailbox, mark original messages as answered/forwarded,
 
990	// remove any draft message.
 
991	acc.WithRLock(func() {
 
992		var changes []store.Change
 
996			if x := recover(); x != nil {
 
998					metricServerErrors.WithLabelValues("submit").Inc()
 
1003		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1004			if m.DraftMessageID > 0 {
 
1005				var nchanges []store.Change
 
1006				modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
 
1007				changes = append(changes, nchanges...)
 
1008				// On-disk file is removed after lock.
 
1011			if m.ResponseMessageID > 0 {
 
1012				rm := xmessageID(ctx, tx, m.ResponseMessageID)
 
1019				if !rm.Junk && !rm.Notjunk {
 
1022				if rm.Flags != oflags {
 
1023					modseq, err = acc.NextModSeq(tx)
 
1024					xcheckf(ctx, err, "next modseq")
 
1026					err := tx.Update(&rm)
 
1027					xcheckf(ctx, err, "updating flags of replied/forwarded message")
 
1028					changes = append(changes, rm.ChangeFlags(oflags))
 
1030					err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
 
1031					xcheckf(ctx, err, "retraining messages after reply/forward")
 
1034				// Move messages from this thread still in this mailbox to the designated Archive
 
1036				if m.ArchiveThread {
 
1037					mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
 
1038					if err == bstore.ErrAbsent {
 
1039						xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
 
1041					xcheckf(ctx, err, "looking up designated archive mailbox")
 
1044					q := bstore.QueryTx[store.Message](tx)
 
1045					q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID})
 
1046					q.FilterEqual("Expunged", false)
 
1047					err = q.IDs(&msgIDs)
 
1048					xcheckf(ctx, err, "listing messages in thread to archive")
 
1049					if len(msgIDs) > 0 {
 
1050						var nchanges []store.Change
 
1051						modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
 
1052						changes = append(changes, nchanges...)
 
1057			sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
 
1058			if err == bstore.ErrAbsent {
 
1059				// There is no mailbox designated as Sent mailbox, so we're done.
 
1062			xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
 
1065				modseq, err = acc.NextModSeq(tx)
 
1066				xcheckf(ctx, err, "next modseq")
 
1069			// If there were bcc headers, prepend those to the stored message only, before the
 
1070			// DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
 
1071			// message won't validate with DKIM anymore, which is fine.
 
1072			if len(bccAddrs) > 0 {
 
1073				var sb strings.Builder
 
1074				xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
 
1075				xbcc.HeaderAddrs("Bcc", bccAddrs)
 
1077				msgPrefix = sb.String() + msgPrefix
 
1080			sentm := store.Message{
 
1083				MailboxID:     sentmb.ID,
 
1084				MailboxOrigID: sentmb.ID,
 
1085				Flags:         store.Flags{Notjunk: true, Seen: true},
 
1086				Size:          int64(len(msgPrefix)) + xc.Size,
 
1087				MsgPrefix:     []byte(msgPrefix),
 
1090			if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
 
1091				xcheckf(ctx, err, "checking quota")
 
1093				xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
 
1096			// Update mailbox before delivery, which changes uidnext.
 
1097			sentmb.Add(sentm.MailboxCounts())
 
1098			err = tx.Update(&sentmb)
 
1099			xcheckf(ctx, err, "updating sent mailbox for counts")
 
1101			err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
 
1103				metricSubmission.WithLabelValues("storesenterror").Inc()
 
1106			xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
 
1108			changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
 
1111		store.BroadcastChanges(acc, changes)
 
1114	// Remove on-disk file for removed draft message.
 
1115	if m.DraftMessageID > 0 {
 
1116		p := acc.MessagePath(m.DraftMessageID)
 
1118		log.Check(err, "removing draft message file")
 
1122// MessageMove moves messages to another mailbox. If the message is already in
 
1123// the mailbox an error is returned.
 
1124func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
 
1125	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1126	acc := reqInfo.Account
 
1129	xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
 
1132var xops = webops.XOps{
 
1135	Checkuserf: xcheckuserf,
 
1138// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
 
1139func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
 
1140	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1141	acc := reqInfo.Account
 
1144	if len(messageIDs) == 0 {
 
1148	xops.MessageDelete(ctx, log, acc, messageIDs)
 
1151// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
 
1152// flags should be lower-case, but will be converted and verified.
 
1153func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
 
1154	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1155	acc := reqInfo.Account
 
1158	xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
 
1161// FlagsClear clears flags, either system flags like \Seen or custom keywords.
 
1162func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
 
1163	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1164	acc := reqInfo.Account
 
1167	xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
 
1170// MailboxCreate creates a new mailbox.
 
1171func (Webmail) MailboxCreate(ctx context.Context, name string) {
 
1172	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1173	acc := reqInfo.Account
 
1176	name, _, err = store.CheckMailboxName(name, false)
 
1177	xcheckuserf(ctx, err, "checking mailbox name")
 
1179	acc.WithWLock(func() {
 
1180		var changes []store.Change
 
1181		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1184			changes, _, exists, err = acc.MailboxCreate(tx, name)
 
1186				xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
 
1188			xcheckf(ctx, err, "creating mailbox")
 
1191		store.BroadcastChanges(acc, changes)
 
1195// MailboxDelete deletes a mailbox and all its messages.
 
1196func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
 
1197	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1198	acc := reqInfo.Account
 
1201	// Messages to remove after having broadcasted the removal of messages.
 
1202	var removeMessageIDs []int64
 
1204	acc.WithWLock(func() {
 
1205		var changes []store.Change
 
1207		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1208			mb := xmailboxID(ctx, tx, mailboxID)
 
1209			if mb.Name == "Inbox" {
 
1210				// Inbox is special in IMAP and cannot be removed.
 
1211				xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
 
1214			var hasChildren bool
 
1216			changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
 
1218				xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
 
1220			xcheckf(ctx, err, "deleting mailbox")
 
1223		store.BroadcastChanges(acc, changes)
 
1226	for _, mID := range removeMessageIDs {
 
1227		p := acc.MessagePath(mID)
 
1229		log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
 
1233// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
 
1234// its child mailboxes.
 
1235func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
 
1236	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1237	acc := reqInfo.Account
 
1240	var expunged []store.Message
 
1242	acc.WithWLock(func() {
 
1243		var changes []store.Change
 
1245		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1246			mb := xmailboxID(ctx, tx, mailboxID)
 
1248			modseq, err := acc.NextModSeq(tx)
 
1249			xcheckf(ctx, err, "next modseq")
 
1251			// Mark messages as expunged.
 
1252			qm := bstore.QueryTx[store.Message](tx)
 
1253			qm.FilterNonzero(store.Message{MailboxID: mb.ID})
 
1254			qm.FilterEqual("Expunged", false)
 
1256			qm.Gather(&expunged)
 
1257			_, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
 
1258			xcheckf(ctx, err, "deleting messages")
 
1260			// Remove Recipients.
 
1261			anyIDs := make([]any, len(expunged))
 
1262			for i, m := range expunged {
 
1265			qmr := bstore.QueryTx[store.Recipient](tx)
 
1266			qmr.FilterEqual("MessageID", anyIDs...)
 
1267			_, err = qmr.Delete()
 
1268			xcheckf(ctx, err, "removing message recipients")
 
1270			// Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
 
1272			uids := make([]store.UID, len(expunged))
 
1273			for i, m := range expunged {
 
1274				m.Expunged = false // Gather returns updated values.
 
1275				mb.Sub(m.MailboxCounts())
 
1279				expunged[i].Junk = false
 
1280				expunged[i].Notjunk = false
 
1283			err = tx.Update(&mb)
 
1284			xcheckf(ctx, err, "updating mailbox for counts")
 
1286			err = acc.AddMessageSize(log, tx, -totalSize)
 
1287			xcheckf(ctx, err, "updating disk usage")
 
1289			err = acc.RetrainMessages(ctx, log, tx, expunged, true)
 
1290			xcheckf(ctx, err, "retraining expunged messages")
 
1292			chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
 
1293			changes = []store.Change{chremove, mb.ChangeCounts()}
 
1296		store.BroadcastChanges(acc, changes)
 
1299	for _, m := range expunged {
 
1300		p := acc.MessagePath(m.ID)
 
1302		log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
 
1306// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
 
1307// ID and its messages are unchanged.
 
1308func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
 
1309	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1310	acc := reqInfo.Account
 
1312	// Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
 
1313	// standard. We can just say no.
 
1315	newName, _, err = store.CheckMailboxName(newName, false)
 
1316	xcheckuserf(ctx, err, "checking new mailbox name")
 
1318	acc.WithWLock(func() {
 
1319		var changes []store.Change
 
1321		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1322			mbsrc := xmailboxID(ctx, tx, mailboxID)
 
1324			var isInbox, notExists, alreadyExists bool
 
1325			changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
 
1326			if isInbox || notExists || alreadyExists {
 
1327				xcheckuserf(ctx, err, "renaming mailbox")
 
1329			xcheckf(ctx, err, "renaming mailbox")
 
1332		store.BroadcastChanges(acc, changes)
 
1336// CompleteRecipient returns autocomplete matches for a recipient, returning the
 
1337// matches, most recently used first, and whether this is the full list and further
 
1338// requests for longer prefixes aren't necessary.
 
1339func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
 
1340	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1341	acc := reqInfo.Account
 
1343	search = strings.ToLower(search)
 
1345	var matches []string
 
1347	acc.WithRLock(func() {
 
1348		xdbread(ctx, acc, func(tx *bstore.Tx) {
 
1353			seen := map[key]bool{}
 
1355			q := bstore.QueryTx[store.Recipient](tx)
 
1357			err := q.ForEach(func(r store.Recipient) error {
 
1358				k := key{r.Localpart, r.Domain}
 
1362				// todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
 
1363				address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
 
1364				if !strings.Contains(strings.ToLower(address), search) {
 
1367				if len(matches) >= 20 {
 
1369					return bstore.StopForEach
 
1372				// Look in the message that was sent for a name along with the address.
 
1373				m := store.Message{ID: r.MessageID}
 
1375				xcheckf(ctx, err, "get sent message")
 
1376				if !m.Expunged && m.ParsedBuf != nil {
 
1377					var part message.Part
 
1378					err := json.Unmarshal(m.ParsedBuf, &part)
 
1379					xcheckf(ctx, err, "parsing part")
 
1381					dom, err := dns.ParseDomain(r.Domain)
 
1382					xcheckf(ctx, err, "parsing domain of recipient")
 
1386					checkAddrs := func(l []message.Address) {
 
1390						for _, a := range l {
 
1391							if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
 
1393								address = addressString(a, false)
 
1398					if part.Envelope != nil {
 
1399						env := part.Envelope
 
1406				matches = append(matches, address)
 
1410			xcheckf(ctx, err, "listing recipients")
 
1416// addressString returns an address into a string as it could be used in a message header.
 
1417func addressString(a message.Address, smtputf8 bool) string {
 
1419	dom, err := dns.ParseDomain(a.Host)
 
1421		if smtputf8 && dom.Unicode != "" {
 
1427	s := "<" + a.User + "@" + host + ">"
 
1429		// todo: properly encoded/escaped name
 
1430		s = a.Name + " " + s
 
1435// MailboxSetSpecialUse sets the special use flags of a mailbox.
 
1436func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
 
1437	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1438	acc := reqInfo.Account
 
1440	acc.WithWLock(func() {
 
1441		var changes []store.Change
 
1443		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1444			xmb := xmailboxID(ctx, tx, mb.ID)
 
1446			// We only allow a single mailbox for each flag (JMAP requirement). So for any flag
 
1447			// we set, we clear it for the mailbox(es) that had it, if any.
 
1448			clearPrevious := func(clear bool, specialUse string) {
 
1452				var ombl []store.Mailbox
 
1453				q := bstore.QueryTx[store.Mailbox](tx)
 
1454				q.FilterNotEqual("ID", mb.ID)
 
1455				q.FilterEqual(specialUse, true)
 
1457				_, err := q.UpdateField(specialUse, false)
 
1458				xcheckf(ctx, err, "updating previous special-use mailboxes")
 
1460				for _, omb := range ombl {
 
1461					changes = append(changes, omb.ChangeSpecialUse())
 
1464			clearPrevious(mb.Archive, "Archive")
 
1465			clearPrevious(mb.Draft, "Draft")
 
1466			clearPrevious(mb.Junk, "Junk")
 
1467			clearPrevious(mb.Sent, "Sent")
 
1468			clearPrevious(mb.Trash, "Trash")
 
1470			xmb.SpecialUse = mb.SpecialUse
 
1471			err := tx.Update(&xmb)
 
1472			xcheckf(ctx, err, "updating special-use flags for mailbox")
 
1473			changes = append(changes, xmb.ChangeSpecialUse())
 
1476		store.BroadcastChanges(acc, changes)
 
1480// ThreadCollapse saves the ThreadCollapse field for the messages and its
 
1481// children. The messageIDs are typically thread roots. But not all roots
 
1482// (without parent) of a thread need to have the same collapsed state.
 
1483func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
 
1484	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1485	acc := reqInfo.Account
 
1487	if len(messageIDs) == 0 {
 
1488		xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
 
1491	acc.WithWLock(func() {
 
1492		changes := make([]store.Change, 0, len(messageIDs))
 
1493		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1494			// Gather ThreadIDs to list all potential messages, for a way to get all potential
 
1495			// (child) messages. Further refined in FilterFn.
 
1496			threadIDs := map[int64]struct{}{}
 
1497			msgIDs := map[int64]struct{}{}
 
1498			for _, id := range messageIDs {
 
1499				m := store.Message{ID: id}
 
1501				if err == bstore.ErrAbsent {
 
1502					xcheckuserf(ctx, err, "get message")
 
1504				xcheckf(ctx, err, "get message")
 
1505				threadIDs[m.ThreadID] = struct{}{}
 
1506				msgIDs[id] = struct{}{}
 
1509			var updated []store.Message
 
1510			q := bstore.QueryTx[store.Message](tx)
 
1511			q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
 
1512			q.FilterNotEqual("ThreadCollapsed", collapse)
 
1513			q.FilterFn(func(tm store.Message) bool {
 
1514				for _, id := range tm.ThreadParentIDs {
 
1515					if _, ok := msgIDs[id]; ok {
 
1519				_, ok := msgIDs[tm.ID]
 
1523			q.SortAsc("ID") // Consistent order for testing.
 
1524			_, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
 
1525			xcheckf(ctx, err, "updating collapse in database")
 
1527			for _, m := range updated {
 
1528				changes = append(changes, m.ChangeThread())
 
1531		store.BroadcastChanges(acc, changes)
 
1535// ThreadMute saves the ThreadMute field for the messages and their children.
 
1536// If messages are muted, they are also marked collapsed.
 
1537func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
 
1538	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1539	acc := reqInfo.Account
 
1541	if len(messageIDs) == 0 {
 
1542		xcheckuserf(ctx, errors.New("no messages"), "setting mute")
 
1545	acc.WithWLock(func() {
 
1546		changes := make([]store.Change, 0, len(messageIDs))
 
1547		xdbwrite(ctx, acc, func(tx *bstore.Tx) {
 
1548			threadIDs := map[int64]struct{}{}
 
1549			msgIDs := map[int64]struct{}{}
 
1550			for _, id := range messageIDs {
 
1551				m := store.Message{ID: id}
 
1553				if err == bstore.ErrAbsent {
 
1554					xcheckuserf(ctx, err, "get message")
 
1556				xcheckf(ctx, err, "get message")
 
1557				threadIDs[m.ThreadID] = struct{}{}
 
1558				msgIDs[id] = struct{}{}
 
1561			var updated []store.Message
 
1563			q := bstore.QueryTx[store.Message](tx)
 
1564			q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
 
1565			q.FilterFn(func(tm store.Message) bool {
 
1566				if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
 
1569				for _, id := range tm.ThreadParentIDs {
 
1570					if _, ok := msgIDs[id]; ok {
 
1574				_, ok := msgIDs[tm.ID]
 
1578			fields := map[string]any{"ThreadMuted": mute}
 
1580				fields["ThreadCollapsed"] = true
 
1582			_, err := q.UpdateFields(fields)
 
1583			xcheckf(ctx, err, "updating mute in database")
 
1585			for _, m := range updated {
 
1586				changes = append(changes, m.ChangeThread())
 
1589		store.BroadcastChanges(acc, changes)
 
1593// SecurityResult indicates whether a security feature is supported.
 
1594type SecurityResult string
 
1597	SecurityResultError SecurityResult = "error"
 
1598	SecurityResultNo    SecurityResult = "no"
 
1599	SecurityResultYes   SecurityResult = "yes"
 
1600	// Unknown whether supported. Finding out may only be (reasonably) possible when
 
1601	// trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
 
1603	SecurityResultUnknown SecurityResult = "unknown"
 
1606// RecipientSecurity is a quick analysis of the security properties of delivery to
 
1607// the recipient (domain).
 
1608type RecipientSecurity struct {
 
1609	// Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
 
1610	// recent delivery attempt. Will be "unknown" if no delivery to the domain has been
 
1612	STARTTLS SecurityResult
 
1614	// Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
 
1616	MTASTS SecurityResult
 
1618	// Whether MX lookup response was DNSSEC-signed.
 
1619	DNSSEC SecurityResult
 
1621	// Whether first delivery destination has DANE records.
 
1624	// Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
 
1625	// Will be "unknown" if no delivery to the domain has been attempted yet.
 
1626	RequireTLS SecurityResult
 
1629// RecipientSecurity looks up security properties of the address in the
 
1630// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
 
1631func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
 
1632	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1635	resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
 
1636	return recipientSecurity(ctx, log, resolver, messageAddressee)
 
1639// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
 
1640func logPanic(ctx context.Context) {
 
1645	log := pkglog.WithContext(ctx)
 
1646	log.Error("recover from panic", slog.Any("panic", x))
 
1648	metrics.PanicInc(metrics.Webmail)
 
1651// separate function for testing with mocked resolver.
 
1652func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
 
1653	rs := RecipientSecurity{
 
1654		SecurityResultUnknown,
 
1655		SecurityResultUnknown,
 
1656		SecurityResultUnknown,
 
1657		SecurityResultUnknown,
 
1658		SecurityResultUnknown,
 
1661	msgAddr, err := mail.ParseAddress(messageAddressee)
 
1663		return rs, fmt.Errorf("parsing message addressee: %v", err)
 
1666	addr, err := smtp.ParseAddress(msgAddr.Address)
 
1668		return rs, fmt.Errorf("parsing address: %v", err)
 
1671	var wg sync.WaitGroup
 
1679		policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
 
1680		if policy != nil && policy.Mode == mtasts.ModeEnforce {
 
1681			rs.MTASTS = SecurityResultYes
 
1682		} else if err == nil {
 
1683			rs.MTASTS = SecurityResultNo
 
1685			rs.MTASTS = SecurityResultError
 
1695		_, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
 
1697			rs.DNSSEC = SecurityResultError
 
1700		if origNextHopAuthentic && expandedNextHopAuthentic {
 
1701			rs.DNSSEC = SecurityResultYes
 
1703			rs.DNSSEC = SecurityResultNo
 
1706		if !origNextHopAuthentic {
 
1707			rs.DANE = SecurityResultNo
 
1711		// We're only looking at the first host to deliver to (typically first mx destination).
 
1712		if len(hosts) == 0 || hosts[0].Domain.IsZero() {
 
1713			return // Should not happen.
 
1717		// Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
 
1718		// error result instead of no-DANE result.
 
1719		authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
 
1721			rs.DANE = SecurityResultError
 
1725			rs.DANE = SecurityResultNo
 
1729		daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
 
1731			rs.DANE = SecurityResultError
 
1733		} else if daneRequired {
 
1734			rs.DANE = SecurityResultYes
 
1736			rs.DANE = SecurityResultNo
 
1740	// STARTTLS and RequireTLS
 
1741	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1742	acc := reqInfo.Account
 
1744	err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
 
1745		q := bstore.QueryTx[store.RecipientDomainTLS](tx)
 
1746		q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
 
1748		if err == bstore.ErrAbsent {
 
1750		} else if err != nil {
 
1751			rs.STARTTLS = SecurityResultError
 
1752			rs.RequireTLS = SecurityResultError
 
1753			log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
 
1757			rs.STARTTLS = SecurityResultYes
 
1759			rs.STARTTLS = SecurityResultNo
 
1762			rs.RequireTLS = SecurityResultYes
 
1764			rs.RequireTLS = SecurityResultNo
 
1768	xcheckf(ctx, err, "lookup recipient domain")
 
1775// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
 
1776func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
 
1777	s, err := wordDecoder.DecodeHeader(text)
 
1778	xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
 
1782// SettingsSave saves settings, e.g. for composing.
 
1783func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
 
1784	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1785	acc := reqInfo.Account
 
1788	err := acc.DB.Update(ctx, &settings)
 
1789	xcheckf(ctx, err, "save settings")
 
1792func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
 
1793	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1794	acc := reqInfo.Account
 
1797	xdbread(ctx, acc, func(tx *bstore.Tx) {
 
1798		m := xmessageID(ctx, tx, msgID)
 
1799		mbSrc := xmailboxID(ctx, tx, mbSrcID)
 
1800		mbDst := xmailboxID(ctx, tx, mbDstID)
 
1802		if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
 
1805		rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
 
1807		conf, _ := acc.Conf()
 
1808		dest := conf.Destinations[rcptTo] // May not be present.
 
1809		defaultMailbox := "Inbox"
 
1810		if dest.Mailbox != "" {
 
1811			defaultMailbox = dest.Mailbox
 
1814		// Only suggest rules for messages moved into/out of the default mailbox (Inbox).
 
1815		if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
 
1819		// Check if we have a previous answer "No" answer for moving from/to mailbox.
 
1820		exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
 
1821		xcheckf(ctx, err, "looking up previous response for source mailbox")
 
1825		exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
 
1826		xcheckf(ctx, err, "looking up previous response for destination mailbox")
 
1831		// Parse message for List-Id header.
 
1832		state := msgState{acc: acc}
 
1834		pm, err := parsedMessage(log, m, &state, true, false)
 
1835		xcheckf(ctx, err, "parsing message")
 
1837		// The suggested ruleset. Once all is checked, we'll return it.
 
1838		var nrs *config.Ruleset
 
1840		// If List-Id header is present, we'll treat it as a (mailing) list message.
 
1841		if l, ok := pm.Headers["List-Id"]; ok {
 
1843				log.Debug("not exactly one list-id header", slog.Any("listid", l))
 
1846			var listIDDom dns.Domain
 
1847			listID, listIDDom = parseListID(l[0])
 
1849				log.Debug("invalid list-id header", slog.String("listid", l[0]))
 
1853			// Check if we have a previous "No" answer for this list-id.
 
1854			no := store.RulesetNoListID{
 
1855				RcptToAddress: rcptTo,
 
1857				ToInbox:       mbDst.Name == "Inbox",
 
1859			exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
 
1860			xcheckf(ctx, err, "looking up previous response for list-id")
 
1865			// Find the "ListAllowDomain" to use. We only match and move messages with verified
 
1866			// SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
 
1867			// are subscribed to, and take advantage of any reduced junk filtering.
 
1868			listIDDomStr := listIDDom.Name()
 
1870			doms := m.DKIMDomains
 
1871			if m.MailFromValidated {
 
1872				doms = append(doms, m.MailFromDomain)
 
1874			// Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
 
1875			// of SPF verification of one host.
 
1876			sort.Slice(doms, func(i, j int) bool {
 
1877				return len(doms[i]) < len(doms[j])
 
1879			var listAllowDom string
 
1880			for _, dom := range doms {
 
1881				if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
 
1886			if listAllowDom == "" {
 
1890			listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
 
1891			nrs = &config.Ruleset{
 
1892				HeadersRegexp:   map[string]string{"^list-id$": listIDRegExp},
 
1893				ListAllowDomain: listAllowDom,
 
1894				Mailbox:         mbDst.Name,
 
1897			// Otherwise, try to make a rule based on message "From" address.
 
1898			if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
 
1901			msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
 
1903			no := store.RulesetNoMsgFrom{
 
1904				RcptToAddress:  rcptTo,
 
1905				MsgFromAddress: msgFrom,
 
1906				ToInbox:        mbDst.Name == "Inbox",
 
1908			exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
 
1909			xcheckf(ctx, err, "looking up previous response for message from address")
 
1914			nrs = &config.Ruleset{
 
1915				MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
 
1916				Mailbox:       mbDst.Name,
 
1920		// Only suggest adding/removing rule if it isn't/is present.
 
1922		for _, rs := range dest.Rulesets {
 
1923			xrs := config.Ruleset{
 
1924				MsgFromRegexp:   rs.MsgFromRegexp,
 
1925				HeadersRegexp:   rs.HeadersRegexp,
 
1926				ListAllowDomain: rs.ListAllowDomain,
 
1927				Mailbox:         nrs.Mailbox,
 
1929			if xrs.Equal(*nrs) {
 
1934		isRemove = mbDst.Name == defaultMailbox
 
1936			nrs.Mailbox = mbSrc.Name
 
1938		if isRemove && !have || !isRemove && have {
 
1942		// We'll be returning a suggested ruleset.
 
1943		nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
 
1949// Parse the list-id value (the value between <>) from a list-id header.
 
1950// Returns an empty string if it couldn't be parsed.
 
1951func parseListID(s string) (listID string, dom dns.Domain) {
 
1953	s = strings.TrimRight(s, " \t")
 
1954	if !strings.HasSuffix(s, ">") {
 
1955		return "", dns.Domain{}
 
1958	t := strings.Split(s, "<")
 
1960		return "", dns.Domain{}
 
1963	dom, err := dns.ParseDomain(s)
 
1970func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
 
1971	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1973	err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
 
1974		dest, ok := acc.Destinations[rcptTo]
 
1976			// todo: we could find the catchall address and add the rule, or add the address explicitly.
 
1977			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")
 
1980		nd := map[string]config.Destination{}
 
1981		for addr, d := range acc.Destinations {
 
1984		dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
 
1986		acc.Destinations = nd
 
1988	xcheckf(ctx, err, "saving account with new ruleset")
 
1991func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
 
1992	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
1994	err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
 
1995		dest, ok := acc.Destinations[rcptTo]
 
1997			xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
 
2000		nd := map[string]config.Destination{}
 
2001		for addr, d := range acc.Destinations {
 
2004		var l []config.Ruleset
 
2006		for _, rs := range dest.Rulesets {
 
2007			if rs.Equal(ruleset) {
 
2014			xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
 
2018		acc.Destinations = nd
 
2020	xcheckf(ctx, err, "saving account with new ruleset")
 
2023func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
 
2024	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
2025	acc := reqInfo.Account
 
2029		err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
 
2031		err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
 
2033	xcheckf(ctx, err, "storing user response")
 
2036func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
 
2037	reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
 
2038	acc := reqInfo.Account
 
2040	err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
 
2041	xcheckf(ctx, err, "storing user response")
 
2044func slicesAny[T any](l []T) []any {
 
2045	r := make([]any, len(l))
 
2046	for i, v := range l {
 
2052// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
 
2053func (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) {