1// Package webapisrv implements the server-side of the webapi.
4// In a separate package from webapi, so webapi.Client can be used and imported
5// without including all mox internals. Documentation for the functions is in
11 cryptorand "crypto/rand"
16 htmltemplate "html/template"
31 "github.com/prometheus/client_golang/prometheus"
32 "github.com/prometheus/client_golang/prometheus/promauto"
34 "github.com/mjl-/bstore"
36 "github.com/mjl-/mox/dkim"
37 "github.com/mjl-/mox/dns"
38 "github.com/mjl-/mox/message"
39 "github.com/mjl-/mox/metrics"
40 "github.com/mjl-/mox/mlog"
41 "github.com/mjl-/mox/mox-"
42 "github.com/mjl-/mox/moxio"
43 "github.com/mjl-/mox/moxvar"
44 "github.com/mjl-/mox/queue"
45 "github.com/mjl-/mox/smtp"
46 "github.com/mjl-/mox/store"
47 "github.com/mjl-/mox/webapi"
48 "github.com/mjl-/mox/webauth"
49 "github.com/mjl-/mox/webops"
52var pkglog = mlog.New("webapi", nil)
55 // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission and ../webapisrv/server.go:/metricSubmission
56 metricSubmission = promauto.NewCounterVec(
57 prometheus.CounterOpts{
58 Name: "mox_webapi_submission_total",
59 Help: "Webapi message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.",
65 metricServerErrors = promauto.NewCounterVec(
66 prometheus.CounterOpts{
67 Name: "mox_webapi_errors_total",
68 Help: "Webapi server errors, known values: dkimsign, submit.",
74 metricResults = promauto.NewCounterVec(
75 prometheus.CounterOpts{
76 Name: "mox_webapi_results_total",
77 Help: "HTTP webapi results by method and result.",
79 []string{"method", "result"}, // result: "badauth", "ok", or error code
81 metricDuration = promauto.NewHistogramVec(
82 prometheus.HistogramOpts{
83 Name: "mox_webapi_duration_seconds",
84 Help: "HTTP webhook call duration.",
85 Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 20, 30},
91// We pass the request to the handler so the TLS info can be used for
92// the Received header in submitted messages. Most API calls need just the
96var requestInfoCtxKey ctxKey = "requestInfo"
98type requestInfo struct {
101 Account *store.Account
102 Response http.ResponseWriter // For setting headers for non-JSON responses.
103 Request *http.Request // For Proto and TLS connection state during message submit.
106// todo: show a curl invocation on the method pages
108var docsMethodTemplate = htmltemplate.Must(htmltemplate.New("method").Parse(`<!doctype html>
110 <meta charset="utf-8" />
111 <meta name="robots" content="noindex,nofollow" />
112 <title>Method {{ .Method }} - WebAPI - Mox</title>
114body, html { padding: 1em; font-size: 16px; }
115* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
116h1, h2, h3, h4 { margin-bottom: 1ex; }
117h1 { font-size: 1.2rem; }
118h2 { font-size: 1.1rem; }
119h3, h4 { font-size: 1rem; }
120ul { padding-left: 1rem; }
121p { margin-bottom: 1em; max-width: 50em; }
122[title] { text-decoration: underline; text-decoration-style: dotted; }
123fieldset { border: 0; }
124textarea { width: 100%; max-width: 50em; }
128 <h1><a href="../">WebAPI</a> - Method {{ .Method }}</h1>
129 <form id="webapicall" method="POST">
130 <fieldset id="webapifieldset">
131 <h2>Request JSON</h2>
132 <div><textarea id="webapirequest" name="request" required rows="20">{{ .Request }}</textarea></div>
135 <button type="reset">Reset</button>
136 <button type="submit">Call</button>
139{{ if .ReturnsBytes }}
140 <p>Method has a non-JSON response.</p>
142 <h2>Response JSON</h2>
143 <div><textarea id="webapiresponse" rows="20">{{ .Response }}</textarea></div>
148window.addEventListener('load', () => {
149 window.webapicall.addEventListener('submit', async (e) => {
157 req = JSON.parse(window.webapirequest.value)
159 window.alert('Error parsing request: ' + err.message)
164 window.alert('Empty request')
169 if ({{ .ReturnsBytes }}) {
170 // Just POST to this URL.
175 // Do call ourselves, get response and put it in the response textarea.
176 window.webapifieldset.disabled = true
177 let data = new window.FormData()
178 data.append("request", window.webapirequest.value)
180 const response = await fetch("{{ .Method }}", {body: data, method: "POST"})
181 const text = await response.text()
183 window.webapiresponse.value = JSON.stringify(JSON.parse(text), undefined, '\t')
185 window.webapiresponse.value = text
188 window.alert('Error: ' + err.message)
190 window.webapifieldset.disabled = false
203 mt := reflect.TypeFor[webapi.Methods]()
206 methods = append(methods, mt.Method(i).Name)
208 docsIndexTmpl := htmltemplate.Must(htmltemplate.New("index").Parse(`<!doctype html>
211 <meta charset="utf-8" />
212 <meta name="robots" content="noindex,nofollow" />
213 <title>Webapi - Mox</title>
215body, html { padding: 1em; font-size: 16px; }
216* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
217h1, h2, h3, h4 { margin-bottom: 1ex; }
218h1 { font-size: 1.2rem; }
219h2 { font-size: 1.1rem; }
220h3, h4 { font-size: 1rem; }
221ul { padding-left: 1rem; }
222p { margin-bottom: 1em; max-width: 50em; }
223[title] { text-decoration: underline; text-decoration-style: dotted; }
224fieldset { border: 0; }
228 <h1>Webapi and webhooks</h1>
229 <p>The mox webapi is a simple HTTP/JSON-based API for sending messages and processing incoming messages.</p>
230 <p>Configure webhooks in mox to receive notifications about outgoing delivery event, and/or incoming deliveries of messages.</p>
231 <p>Documentation and examples:</p>
232 <p><a href="{{ .WebapiDocsURL }}">{{ .WebapiDocsURL }}</a></p>
234 <p>The methods below are available in this version of mox. Follow a link for an example request/response JSON, and a button to make an API call.</p>
236{{ range $i, $method := .Methods }}
237 <li><a href="{{ $method }}">{{ $method }}</a></li>
243 webapiDocsURL := "https://pkg.go.dev/github.com/mjl-/mox@" + moxvar.VersionBare + "/webapi/"
244 webhookDocsURL := "https://pkg.go.dev/github.com/mjl-/mox@" + moxvar.VersionBare + "/webhook/"
245 indexArgs := struct {
247 WebhookDocsURL string
249 }{webapiDocsURL, webhookDocsURL, methods}
251 err := docsIndexTmpl.Execute(&b, indexArgs)
253 panic("executing api docs index template: " + err.Error())
255 docsIndex = b.Bytes()
257 mox.NewWebapiHandler = func(maxMsgSize int64, basePath string, isForwarded bool) http.Handler {
258 return NewServer(maxMsgSize, basePath, isForwarded)
262// NewServer returns a new http.Handler for a webapi server.
263func NewServer(maxMsgSize int64, path string, isForwarded bool) http.Handler {
264 return server{maxMsgSize, path, isForwarded}
267// server implements the webapi methods.
269 maxMsgSize int64 // Of outgoing messages.
270 path string // Path webapi is configured under, typically /webapi/, with methods at /webapi/v0/<method>.
271 isForwarded bool // Whether incoming requests are reverse-proxied. Used for getting remote IPs for rate limiting.
274var _ webapi.Methods = server{}
276// ServeHTTP implements http.Handler.
277func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
278 log := pkglog.WithContext(r.Context()) // Take cid from webserver.
280 // Send requests to /webapi/ to /webapi/v0/.
281 if r.URL.Path == "/" {
282 if r.Method != "GET" {
283 http.Error(w, "405 - method not allow", http.StatusMethodNotAllowed)
286 http.Redirect(w, r, s.path+"v0/", http.StatusSeeOther)
289 // Serve short introduction and list to methods at /webapi/v0/.
290 if r.URL.Path == "/v0/" {
291 w.Header().Set("Content-Type", "text/html; charset=utf-8")
296 // Anything else must be a method endpoint.
297 if !strings.HasPrefix(r.URL.Path, "/v0/") {
301 fn := r.URL.Path[len("/v0/"):]
302 log = log.With(slog.String("method", fn))
303 rfn := reflect.ValueOf(s).MethodByName(fn)
304 var zero reflect.Value
305 if rfn == zero || rfn.Type().NumIn() != 2 || rfn.Type().NumOut() != 2 {
306 log.Debug("unknown webapi method")
311 // GET on method returns an example request JSON, a button to call the method,
312 // which either fills a textarea with the response (in case of JSON) or posts to
313 // the URL letting the browser handle the response (e.g. raw message or part).
314 if r.Method == "GET" {
315 formatJSON := func(v any) (string, error) {
317 enc := json.NewEncoder(&b)
318 enc.SetIndent("", "\t")
319 enc.SetEscapeHTML(false)
321 return string(b.String()), err
324 req, err := formatJSON(mox.FillExample(nil, reflect.New(rfn.Type().In(1))).Interface())
326 log.Errorx("formatting request as json", err)
327 http.Error(w, "500 - internal server error - marshal request: "+err.Error(), http.StatusInternalServerError)
330 // todo: could check for io.ReadCloser, but we don't return other interfaces than that one.
331 returnsBytes := rfn.Type().Out(0).Kind() == reflect.Interface
334 resp, err = formatJSON(mox.FillExample(nil, reflect.New(rfn.Type().Out(0))).Interface())
336 log.Errorx("formatting response as json", err)
337 http.Error(w, "500 - internal server error - marshal response: "+err.Error(), http.StatusInternalServerError)
346 }{fn, req, resp, returnsBytes}
347 w.Header().Set("Content-Type", "text/html; charset=utf-8")
348 err = docsMethodTemplate.Execute(w, args)
349 log.Check(err, "executing webapi method template")
351 } else if r.Method != "POST" {
352 http.Error(w, "405 - method not allowed - use get or post", http.StatusMethodNotAllowed)
356 // Account is available during call, but we close it before we start writing a
357 // response, to prevent slow readers from holding a reference for a long time.
358 var acc *store.Account
359 closeAccount := func() {
362 log.Check(err, "closing account")
368 email, password, aok := r.BasicAuth()
370 metricResults.WithLabelValues(fn, "badauth").Inc()
371 log.Debug("missing http basic authentication credentials")
372 w.Header().Set("WWW-Authenticate", "Basic realm=webapi")
373 http.Error(w, "401 - unauthorized - use http basic auth with email address as username", http.StatusUnauthorized)
376 log = log.With(slog.String("username", email))
380 // If remote IP/network resulted in too many authentication failures, refuse to serve.
381 remoteIP := webauth.RemoteIP(log, s.isForwarded, r)
383 metricResults.WithLabelValues(fn, "internal").Inc()
384 log.Debug("cannot find remote ip for rate limiter")
385 http.Error(w, "500 - internal server error - cannot find remote ip", http.StatusInternalServerError)
388 if !mox.LimiterFailedAuth.CanAdd(remoteIP, t0, 1) {
389 metrics.AuthenticationRatelimitedInc("webapi")
390 log.Debug("refusing connection due to many auth failures", slog.Any("remoteip", remoteIP))
391 http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
395 writeError := func(err webapi.Error) {
397 metricResults.WithLabelValues(fn, err.Code).Inc()
399 if err.Code == "server" {
400 log.Errorx("webapi call result", err, slog.String("resultcode", err.Code))
402 log.Infox("webapi call result", err, slog.String("resultcode", err.Code))
405 w.Header().Set("Content-Type", "application/json; charset=utf-8")
406 w.WriteHeader(http.StatusBadRequest)
407 enc := json.NewEncoder(w)
408 enc.SetEscapeHTML(false)
409 werr := enc.Encode(err)
410 log.Check(werr, "writing error response")
413 // Called for all successful JSON responses, not non-JSON responses.
414 writeResponse := func(resp any) {
416 metricResults.WithLabelValues(fn, "ok").Inc()
417 log.Debug("webapi call result", slog.String("resultcode", "ok"))
418 w.Header().Set("Content-Type", "application/json; charset=utf-8")
419 enc := json.NewEncoder(w)
420 enc.SetEscapeHTML(false)
421 werr := enc.Encode(resp)
422 log.Check(werr, "writing error response")
425 la := loginAttempt(r, "webapi", "httpbasic")
426 la.LoginAddress = email
428 store.LoginAttemptAdd(context.Background(), log, la)
429 metricDuration.WithLabelValues(fn).Observe(float64(time.Since(t0)) / float64(time.Second))
433 acc, la.AccountName, err = store.OpenEmailAuth(log, email, password, true)
435 mox.LimiterFailedAuth.Add(remoteIP, t0, 1)
436 if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) || errors.Is(err, store.ErrLoginDisabled) {
437 log.Debug("bad http basic authentication credentials")
438 metricResults.WithLabelValues(fn, "badauth").Inc()
439 la.Result = store.AuthBadCredentials
440 msg := "use http basic auth with email address as username"
441 if errors.Is(err, store.ErrLoginDisabled) {
442 la.Result = store.AuthLoginDisabled
443 msg = "login is disabled for this account"
445 w.Header().Set("WWW-Authenticate", "Basic realm=webapi")
446 http.Error(w, "401 - unauthorized - "+msg, http.StatusUnauthorized)
449 writeError(webapi.Error{Code: "server", Message: "error verifying credentials"})
452 la.AccountName = acc.Name
453 la.Result = store.AuthSuccess
454 mox.LimiterFailedAuth.Reset(remoteIP, t0)
456 ct := r.Header.Get("Content-Type")
457 ct, _, err = mime.ParseMediaType(ct)
459 writeError(webapi.Error{Code: "protocol", Message: "unknown content-type " + r.Header.Get("Content-Type")})
462 if ct == "multipart/form-data" {
463 err = r.ParseMultipartForm(200 * 1024)
468 writeError(webapi.Error{Code: "protocol", Message: "parsing form: " + err.Error()})
472 reqstr := r.PostFormValue("request")
474 writeError(webapi.Error{Code: "protocol", Message: "missing/empty request"})
483 if err, eok := x.(webapi.Error); eok {
487 log.Error("unhandled panic in webapi call", slog.Any("x", x), slog.String("resultcode", "server"))
488 metrics.PanicInc(metrics.Webapi)
490 writeError(webapi.Error{Code: "server", Message: "unhandled error"})
492 req := reflect.New(rfn.Type().In(1))
493 dec := json.NewDecoder(strings.NewReader(reqstr))
494 dec.DisallowUnknownFields()
495 if err := dec.Decode(req.Interface()); err != nil {
496 writeError(webapi.Error{Code: "protocol", Message: fmt.Sprintf("parsing request: %s", err)})
500 reqInfo := requestInfo{log, email, acc, w, r}
501 nctx := context.WithValue(r.Context(), requestInfoCtxKey, reqInfo)
502 resp := rfn.Call([]reflect.Value{reflect.ValueOf(nctx), req.Elem()})
503 if !resp[1].IsZero() {
505 err := resp[1].Interface().(error)
506 if x, eok := err.(webapi.Error); eok {
509 e = webapi.Error{Code: "error", Message: err.Error()}
514 rc, ok := resp[0].Interface().(io.ReadCloser)
516 rv, _ := mox.FillNil(resp[0])
517 writeResponse(rv.Interface())
521 log.Debug("webapi call result", slog.String("resultcode", "ok"))
522 metricResults.WithLabelValues(fn, "ok").Inc()
525 log.Check(err, "closing readcloser")
527 _, err = io.Copy(w, rc)
528 log.Check(err, "writing response to client")
531// loginAttempt initializes a store.LoginAttempt, for adding to the store after
532// filling in the results and other details.
533func loginAttempt(r *http.Request, protocol, authMech string) store.LoginAttempt {
534 remoteIP, _, _ := net.SplitHostPort(r.RemoteAddr)
536 remoteIP = r.RemoteAddr
539 return store.LoginAttempt{
541 TLS: store.LoginAttemptTLS(r.TLS),
544 UserAgent: r.UserAgent(),
545 Result: store.AuthError, // Replaced by caller.
549func xcheckf(err error, format string, args ...any) {
551 msg := fmt.Sprintf(format, args...)
552 panic(webapi.Error{Code: "server", Message: fmt.Sprintf("%s: %s", msg, err)})
556func xcheckuserf(err error, format string, args ...any) {
558 msg := fmt.Sprintf(format, args...)
559 panic(webapi.Error{Code: "user", Message: fmt.Sprintf("%s: %s", msg, err)})
563func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
564 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
568 xcheckf(err, "transaction")
571func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
572 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
576 xcheckf(err, "transaction")
579func xcheckcontrol(s string) {
580 for _, c := range s {
582 xcheckuserf(errors.New("control characters not allowed"), "checking header values")
587func xparseAddress(addr string) smtp.Address {
588 a, err := smtp.ParseAddress(addr)
590 panic(webapi.Error{Code: "badAddress", Message: fmt.Sprintf("parsing address %q: %s", addr, err)})
595func xparseAddresses(l []webapi.NameAddress) ([]message.NameAddress, []smtp.Path) {
596 r := make([]message.NameAddress, len(l))
597 paths := make([]smtp.Path, len(l))
598 for i, a := range l {
599 xcheckcontrol(a.Name)
600 addr := xparseAddress(a.Address)
601 r[i] = message.NameAddress{DisplayName: a.Name, Address: addr}
602 paths[i] = addr.Path()
607func xrandomID(n int) string {
608 return base64.RawURLEncoding.EncodeToString(xrandom(n))
611func xrandom(n int) []byte {
612 buf := make([]byte, n)
613 x, err := cryptorand.Read(buf)
617 panic("short random read")
622func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.SendResult, err error) {
623 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
625 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
627 acc := reqInfo.Account
631 accConf, _ := acc.Conf()
633 if m.Text == "" && m.HTML == "" {
634 return resp, webapi.Error{Code: "missingBody", Message: "at least text or html body required"}
637 if len(m.From) == 0 {
638 m.From = []webapi.NameAddress{{Name: accConf.FullName, Address: reqInfo.LoginAddress}}
639 } else if len(m.From) > 1 {
640 return resp, webapi.Error{Code: "multipleFrom", Message: "multiple from-addresses not allowed"}
642 froms, fromPaths := xparseAddresses(m.From)
643 from, fromPath := froms[0], fromPaths[0]
644 to, toPaths := xparseAddresses(m.To)
645 cc, ccPaths := xparseAddresses(m.CC)
646 bcc, bccPaths := xparseAddresses(m.BCC)
648 recipients := append(append(toPaths, ccPaths...), bccPaths...)
649 addresses := append(append(m.To, m.CC...), m.BCC...)
651 // Check if from address is allowed for account.
652 if ok, disabled := mox.AllowMsgFrom(acc.Name, from.Address); disabled {
653 metricSubmission.WithLabelValues("domaindisabled").Inc()
654 return resp, webapi.Error{Code: "domainDisabled", Message: "domain of from-address is temporarily disabled"}
656 metricSubmission.WithLabelValues("badfrom").Inc()
657 return resp, webapi.Error{Code: "badFrom", Message: "from-address not configured for account"}
660 if len(recipients) == 0 {
661 return resp, webapi.Error{Code: "noRecipients", Message: "no recipients"}
664 // Check outgoing message rate limit.
665 xdbread(ctx, acc, func(tx *bstore.Tx) {
666 msglimit, rcptlimit, err := acc.SendLimitReached(tx, recipients)
668 metricSubmission.WithLabelValues("messagelimiterror").Inc()
669 panic(webapi.Error{Code: "messageLimitReached", Message: "outgoing message rate limit reached"})
670 } else if rcptlimit >= 0 {
671 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
672 panic(webapi.Error{Code: "recipientLimitReached", Message: "outgoing new recipient rate limit reached"})
674 xcheckf(err, "checking send limit")
677 // If we have a non-ascii localpart, we will be sending with smtputf8. We'll go
679 intl := func(l []smtp.Path) bool {
680 for _, p := range l {
681 if p.Localpart.IsInternational() {
687 smtputf8 := intl([]smtp.Path{fromPath}) || intl(toPaths) || intl(ccPaths) || intl(bccPaths)
689 replyTos, replyToPaths := xparseAddresses(m.ReplyTo)
690 for _, rt := range replyToPaths {
691 if rt.Localpart.IsInternational() {
696 // Create file to compose message into.
697 dataFile, err := store.CreateMessageTemp(log, "webapi-submit")
698 xcheckf(err, "creating temporary file for message")
699 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
701 // If writing to the message file fails, we abort immediately.
702 xc := message.NewComposer(dataFile, s.maxMsgSize, smtputf8)
708 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
709 panic(webapi.Error{Code: "messageTooLarge", Message: "message too large"})
710 } else if ok && errors.Is(err, message.ErrCompose) {
711 xcheckf(err, "making message")
716 // Each queued message gets a Received header.
717 // We cannot use VIA, because there is no registered method. We would like to use
718 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
719 // We don't add the IP address of the submitter. Exposing likely not desirable.
720 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
721 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
722 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
723 recvHdrFor := func(rcptTo string) string {
724 recvHdr := &message.HeaderWriter{}
725 // For additional Received-header clauses, see:
726 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
727 // Note: we don't have "via" or "with", there is no registered for webmail.
728 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) //
../rfc/5321:3158
729 if reqInfo.Request.TLS != nil {
730 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
732 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
733 return recvHdr.String()
736 // Outer message headers.
737 xc.HeaderAddrs("From", []message.NameAddress{from})
738 if len(replyTos) > 0 {
739 xc.HeaderAddrs("Reply-To", replyTos)
741 xc.HeaderAddrs("To", to)
742 xc.HeaderAddrs("Cc", cc)
743 // We prepend Bcc headers to the message when adding to the Sent mailbox.
745 xcheckcontrol(m.Subject)
746 xc.Subject(m.Subject)
755 xc.Header("Date", date.Format(message.RFC5322Z))
757 if m.MessageID == "" {
758 m.MessageID = fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
759 } else if !strings.HasPrefix(m.MessageID, "<") || !strings.HasSuffix(m.MessageID, ">") {
760 return resp, webapi.Error{Code: "malformedMessageID", Message: "missing <> in message-id"}
762 xcheckcontrol(m.MessageID)
763 xc.Header("Message-Id", m.MessageID)
765 if len(m.References) > 0 {
766 for _, ref := range m.References {
768 // We don't check for <>'s. If caller just puts in what they got, we don't want to
769 // reject the message.
771 xc.Header("References", strings.Join(m.References, "\r\n\t"))
772 xc.Header("In-Reply-To", m.References[len(m.References)-1])
774 xc.Header("MIME-Version", "1.0")
776 var haveUserAgent bool
777 for _, kv := range req.Headers {
780 xc.Header(kv[0], kv[1])
781 if strings.EqualFold(kv[0], "User-Agent") || strings.EqualFold(kv[0], "X-Mailer") {
786 xc.Header("User-Agent", "mox/"+moxvar.Version)
789 // Whether we have additional separately alternative/inline/attached file(s).
790 mpf := reqInfo.Request.MultipartForm
791 formAlternative := mpf != nil && len(mpf.File["alternativefile"]) > 0
792 formInline := mpf != nil && len(mpf.File["inlinefile"]) > 0
793 formAttachment := mpf != nil && len(mpf.File["attachedfile"]) > 0
795 // MIME structure we'll build:
796 // - multipart/mixed (in case of attached files)
797 // - multipart/related (in case of inline files, we assume they are relevant both text and html part if present)
798 // - multipart/alternative (in case we have both text and html bodies)
799 // - text/plain (optional)
800 // - text/html (optional)
801 // - alternative file, ...
802 // - inline file, ...
803 // - attached file, ...
805 // We keep track of cur, which is where we add new parts to, whether the text or
806 // html part, or the inline or attached files.
807 var cur, mixed, related, alternative *multipart.Writer
808 xcreateMultipart := func(subtype string) *multipart.Writer {
809 mp := multipart.NewWriter(xc)
811 xc.Header("Content-Type", fmt.Sprintf(`multipart/%s; boundary="%s"`, subtype, mp.Boundary()))
814 _, err := cur.CreatePart(textproto.MIMEHeader{"Content-Type": []string{fmt.Sprintf(`multipart/%s; boundary="%s"`, subtype, mp.Boundary())}})
815 xcheckf(err, "adding multipart")
819 xcreatePart := func(header textproto.MIMEHeader) io.Writer {
821 for k, vl := range header {
822 for _, v := range vl {
829 p, err := cur.CreatePart(header)
830 xcheckf(err, "adding part")
833 // We create multiparts from outer structure to inner. Then for each we add its
834 // inner parts and close the multipart.
835 if len(req.AttachedFiles) > 0 || formAttachment {
836 mixed = xcreateMultipart("mixed")
839 if len(req.InlineFiles) > 0 || formInline {
840 related = xcreateMultipart("related")
843 if m.Text != "" && m.HTML != "" || len(req.AlternativeFiles) > 0 || formAlternative {
844 alternative = xcreateMultipart("alternative")
848 textBody, ct, cte := xc.TextPart("plain", m.Text)
849 tp := xcreatePart(textproto.MIMEHeader{"Content-Type": []string{ct}, "Content-Transfer-Encoding": []string{cte}})
850 _, err := tp.Write([]byte(textBody))
851 xcheckf(err, "write text part")
854 htmlBody, ct, cte := xc.TextPart("html", m.HTML)
855 tp := xcreatePart(textproto.MIMEHeader{"Content-Type": []string{ct}, "Content-Transfer-Encoding": []string{cte}})
856 _, err := tp.Write([]byte(htmlBody))
857 xcheckf(err, "write html part")
860 xaddFileBase64 := func(ct string, inline bool, filename string, cid string, base64Data string) {
861 h := textproto.MIMEHeader{}
866 cd := mime.FormatMediaType(disp, map[string]string{"filename": filename})
868 h.Set("Content-Type", ct)
869 h.Set("Content-Disposition", cd)
871 h.Set("Content-ID", cid)
873 h.Set("Content-Transfer-Encoding", "base64")
876 for len(base64Data) > 0 {
878 n := min(len(line), 78)
879 line, base64Data = base64Data[:n], base64Data[n:]
880 _, err := p.Write([]byte(line))
881 xcheckf(err, "writing attachment")
882 _, err = p.Write([]byte("\r\n"))
883 xcheckf(err, "writing attachment")
886 xaddJSONFiles := func(l []webapi.File, inline bool) {
887 for _, f := range l {
888 if f.ContentType == "" {
889 buf, _ := io.ReadAll(io.LimitReader(base64.NewDecoder(base64.StdEncoding, strings.NewReader(f.Data)), 512))
890 f.ContentType = http.DetectContentType(buf)
891 if f.ContentType == "application/octet-stream" {
896 // Ensure base64 is valid, then we'll write the original string.
897 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(f.Data)))
898 xcheckuserf(err, "parsing attachment as base64")
900 xaddFileBase64(f.ContentType, inline, f.Name, f.ContentID, f.Data)
903 xaddFile := func(fh *multipart.FileHeader, inline bool) {
905 xcheckf(err, "open uploaded file")
908 log.Check(err, "closing uploaded file")
911 ct := fh.Header.Get("Content-Type")
913 buf, err := io.ReadAll(io.LimitReader(f, 512))
915 ct = http.DetectContentType(buf)
917 _, err = f.Seek(0, 0)
918 xcheckf(err, "rewind uploaded file after content-detection")
919 if ct == "application/octet-stream" {
924 h := textproto.MIMEHeader{}
929 cd := mime.FormatMediaType(disp, map[string]string{"filename": fh.Filename})
932 h.Set("Content-Type", ct)
934 h.Set("Content-Disposition", cd)
935 cid := fh.Header.Get("Content-ID")
937 h.Set("Content-ID", cid)
939 h.Set("Content-Transfer-Encoding", "base64")
941 bw := moxio.Base64Writer(p)
942 _, err = io.Copy(bw, f)
943 xcheckf(err, "adding uploaded file")
945 xcheckf(err, "flushing uploaded file")
949 xaddJSONFiles(req.AlternativeFiles, true)
951 for _, fh := range mpf.File["alternativefile"] {
955 if alternative != nil {
956 err := alternative.Close()
957 xcheckf(err, "closing alternative part")
962 xaddJSONFiles(req.InlineFiles, true)
964 for _, fh := range mpf.File["inlinefile"] {
969 err := related.Close()
970 xcheckf(err, "closing related part")
974 xaddJSONFiles(req.AttachedFiles, false)
976 for _, fh := range mpf.File["attachedfile"] {
982 xcheckf(err, "closing mixed part")
988 // Add DKIM-Signature headers.
990 fd := from.Address.Domain
991 confDom, _ := mox.Conf.Domain(fd)
992 if confDom.Disabled {
993 xcheckuserf(mox.ErrDomainDisabled, "checking domain")
995 selectors := mox.DKIMSelectors(confDom.DKIM)
996 if len(selectors) > 0 {
997 dkimHeaders, err := dkim.Sign(ctx, log.Logger, from.Address.Localpart, fd, selectors, smtputf8, dataFile)
999 metricServerErrors.WithLabelValues("dkimsign").Inc()
1001 xcheckf(err, "sign dkim")
1003 msgPrefix = dkimHeaders
1006 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
1007 xcheckf(err, "parsing login address")
1008 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
1009 var localpartBase string
1011 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0]
1013 fromIDs := make([]string, len(recipients))
1014 qml := make([]queue.Msg, len(recipients))
1016 for i, rcpt := range recipients {
1019 fromIDs[i] = xrandomID(16)
1020 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromIDs[i])
1023 // Don't use per-recipient unique message prefix when multiple recipients are
1024 // present, we want to keep the message identical.
1026 if len(recipients) == 1 {
1027 recvRcpt = rcpt.XString(smtputf8)
1029 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
1030 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
1031 qm := queue.MakeMsg(fp, rcpt, xc.Has8bit, xc.SMTPUTF8, msgSize, m.MessageID, []byte(rcptMsgPrefix), req.RequireTLS, now, m.Subject)
1032 qm.FromID = fromIDs[i]
1033 qm.Extra = req.Extra
1034 if req.FutureRelease != nil {
1035 ival := time.Until(*req.FutureRelease)
1036 if ival > queue.FutureReleaseIntervalMax {
1037 xcheckuserf(fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
1039 qm.NextAttempt = *req.FutureRelease
1040 qm.FutureReleaseRequest = "until;" + req.FutureRelease.Format(time.RFC3339)
1041 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
1045 err = queue.Add(ctx, log, acc.Name, dataFile, qml...)
1047 metricSubmission.WithLabelValues("queueerror").Inc()
1049 xcheckf(err, "adding messages to the delivery queue")
1050 metricSubmission.WithLabelValues("ok").Inc()
1052 // Message has been added to the queue. Ensure we finish the work.
1053 ctx = context.WithoutCancel(ctx)
1056 // Append message to Sent mailbox and mark original messages as answered/forwarded.
1057 acc.WithRLock(func() {
1058 var changes []store.Change
1064 p := acc.MessagePath(sentID)
1066 log.Check(err, "removing sent message file after error", slog.String("path", p))
1069 if x := recover(); x != nil {
1071 metricServerErrors.WithLabelValues("submit").Inc()
1076 xdbwrite(ctx, reqInfo.Account, func(tx *bstore.Tx) {
1077 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1078 if err == bstore.ErrAbsent {
1079 // There is no mailbox designated as Sent mailbox, so we're done.
1082 xcheckf(err, "message submitted to queue, adding to Sent mailbox")
1084 modseq, err := acc.NextModSeq(tx)
1085 xcheckf(err, "next modseq")
1087 // If there were bcc headers, prepend those to the stored message only, before the
1088 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored message
1089 // won't validate with DKIM anymore, which is fine.
1091 var sb strings.Builder
1092 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1093 xbcc.HeaderAddrs("Bcc", bcc)
1095 msgPrefix = sb.String() + msgPrefix
1098 sentm := store.Message{
1101 MailboxID: sentmb.ID,
1102 MailboxOrigID: sentmb.ID,
1103 Flags: store.Flags{Notjunk: true, Seen: true},
1104 Size: int64(len(msgPrefix)) + xc.Size,
1105 MsgPrefix: []byte(msgPrefix),
1108 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1109 if err != nil && errors.Is(err, store.ErrOverQuota) {
1110 panic(webapi.Error{Code: "sentOverQuota", Message: fmt.Sprintf("message was sent, but not stored in sent mailbox: %v", err)})
1111 } else if err != nil {
1112 metricSubmission.WithLabelValues("storesenterror").Inc()
1115 xcheckf(err, "message submitted to queue, appending message to Sent mailbox")
1118 err = tx.Update(&sentmb)
1119 xcheckf(err, "updating mailbox")
1121 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1123 sentID = 0 // Commit.
1125 store.BroadcastChanges(acc, changes)
1129 submissions := make([]webapi.Submission, len(qml))
1130 for i, qm := range qml {
1131 submissions[i] = webapi.Submission{
1132 Address: addresses[i].Address,
1137 resp = webapi.SendResult{
1138 MessageID: m.MessageID,
1139 Submissions: submissions,
1144func (s server) SuppressionList(ctx context.Context, req webapi.SuppressionListRequest) (resp webapi.SuppressionListResult, err error) {
1145 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1146 resp.Suppressions, err = queue.SuppressionList(ctx, reqInfo.Account.Name)
1150func (s server) SuppressionAdd(ctx context.Context, req webapi.SuppressionAddRequest) (resp webapi.SuppressionAddResult, err error) {
1151 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1152 addr := xparseAddress(req.EmailAddress)
1153 sup := webapi.Suppression{
1154 Account: reqInfo.Account.Name,
1158 err = queue.SuppressionAdd(ctx, addr.Path(), &sup)
1162func (s server) SuppressionRemove(ctx context.Context, req webapi.SuppressionRemoveRequest) (resp webapi.SuppressionRemoveResult, err error) {
1163 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1164 addr := xparseAddress(req.EmailAddress)
1165 err = queue.SuppressionRemove(ctx, reqInfo.Account.Name, addr.Path())
1169func (s server) SuppressionPresent(ctx context.Context, req webapi.SuppressionPresentRequest) (resp webapi.SuppressionPresentResult, err error) {
1170 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1171 addr := xparseAddress(req.EmailAddress)
1172 xcheckuserf(err, "parsing address %q", req.EmailAddress)
1173 sup, err := queue.SuppressionLookup(ctx, reqInfo.Account.Name, addr.Path())
1180func xwebapiAddresses(l []message.Address) (r []webapi.NameAddress) {
1181 r = make([]webapi.NameAddress, len(l))
1182 for i, ma := range l {
1183 dom, err := dns.ParseDomain(ma.Host)
1184 xcheckf(err, "parsing host %q for address", ma.Host)
1185 lp, err := smtp.ParseLocalpart(ma.User)
1186 xcheckf(err, "parsing localpart %q for address", ma.User)
1187 path := smtp.Path{Localpart: lp, IPDomain: dns.IPDomain{Domain: dom}}
1188 r[i] = webapi.NameAddress{Name: ma.Name, Address: path.XString(true)}
1193// caller should hold account lock.
1194func xmessageGet(ctx context.Context, acc *store.Account, msgID int64) (store.Message, store.Mailbox) {
1195 m := store.Message{ID: msgID}
1196 var mb store.Mailbox
1197 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1198 if err := tx.Get(&m); err == bstore.ErrAbsent || err == nil && m.Expunged {
1199 panic(webapi.Error{Code: "messageNotFound", Message: "message not found"})
1202 mb, err = store.MailboxID(tx, m.MailboxID)
1204 return fmt.Errorf("get mailbox: %v", err)
1208 xcheckf(err, "get message")
1212func (s server) MessageGet(ctx context.Context, req webapi.MessageGetRequest) (resp webapi.MessageGetResult, err error) {
1213 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1215 acc := reqInfo.Account
1218 var mb store.Mailbox
1219 var msgr *store.MsgReader
1220 acc.WithRLock(func() {
1221 m, mb = xmessageGet(ctx, acc, req.MsgID)
1222 msgr = acc.MessageReader(m)
1227 log.Check(err, "cleaning up message reader")
1231 p, err := m.LoadPart(msgr)
1232 xcheckf(err, "load parsed message")
1234 var env message.Envelope
1235 if p.Envelope != nil {
1238 text, html, _, err := webops.ReadableParts(p, 1*1024*1024)
1240 log.Debugx("looking for text and html content in message", err)
1247 // Parse References message header.
1248 h, err := p.Header()
1250 log.Debugx("parsing headers for References", err)
1253 for _, s := range h.Values("References") {
1254 s = strings.ReplaceAll(s, "\t", " ")
1255 for _, w := range strings.Split(s, " ") {
1257 refs = append(refs, w)
1261 if env.InReplyTo != "" && !slices.Contains(refs, env.InReplyTo) {
1262 // References are ordered, most recent first. In-Reply-To is less powerful/older.
1263 // So if both are present, give References preference, prepending the In-Reply-To
1265 refs = append([]string{env.InReplyTo}, refs...)
1268 msg := webapi.Message{
1269 From: xwebapiAddresses(env.From),
1270 To: xwebapiAddresses(env.To),
1271 CC: xwebapiAddresses(env.CC),
1272 BCC: xwebapiAddresses(env.BCC),
1273 ReplyTo: xwebapiAddresses(env.ReplyTo),
1274 MessageID: env.MessageID,
1277 Subject: env.Subject,
1278 Text: strings.ReplaceAll(text, "\r\n", "\n"),
1279 HTML: strings.ReplaceAll(html, "\r\n", "\n"),
1283 if d, err := dns.ParseDomain(m.MsgFromDomain); err == nil {
1284 msgFrom = smtp.NewAddress(m.MsgFromLocalpart, d).Pack(true)
1287 if m.RcptToDomain != "" {
1288 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1290 meta := webapi.MessageMeta{
1293 Flags: append(m.Flags.Strings(), m.Keywords...),
1294 MailFrom: m.MailFrom,
1295 MailFromValidated: m.MailFromValidated,
1298 MsgFromValidated: m.MsgFromValidated,
1299 DKIMVerifiedDomains: m.DKIMDomains,
1300 RemoteIP: m.RemoteIP,
1301 MailboxName: mb.Name,
1304 structure, err := queue.PartStructure(log, &p)
1305 xcheckf(err, "parsing structure")
1307 result := webapi.MessageGetResult{
1309 Structure: structure,
1315func (s server) MessageRawGet(ctx context.Context, req webapi.MessageRawGetRequest) (resp io.ReadCloser, err error) {
1316 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1317 acc := reqInfo.Account
1320 var msgr *store.MsgReader
1321 acc.WithRLock(func() {
1322 m, _ = xmessageGet(ctx, acc, req.MsgID)
1323 msgr = acc.MessageReader(m)
1326 reqInfo.Response.Header().Set("Content-Type", "text/plain")
1330func (s server) MessagePartGet(ctx context.Context, req webapi.MessagePartGetRequest) (resp io.ReadCloser, err error) {
1331 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1333 acc := reqInfo.Account
1336 var msgr *store.MsgReader
1337 acc.WithRLock(func() {
1338 m, _ = xmessageGet(ctx, acc, req.MsgID)
1339 msgr = acc.MessageReader(m)
1344 log.Check(err, "cleaning up message reader")
1348 p, err := m.LoadPart(msgr)
1349 xcheckf(err, "load parsed message")
1351 for i, index := range req.PartPath {
1352 if index < 0 || index >= len(p.Parts) {
1353 return nil, webapi.Error{Code: "partNotFound", Message: fmt.Sprintf("part %d at index %d not found", index, i)}
1360 }{Reader: p.Reader(), Closer: msgr}, nil
1363var xops = webops.XOps{
1365 Checkf: func(ctx context.Context, err error, format string, args ...any) {
1366 xcheckf(err, format, args...)
1368 Checkuserf: func(ctx context.Context, err error, format string, args ...any) {
1369 if err != nil && errors.Is(err, webops.ErrMessageNotFound) {
1370 msg := fmt.Sprintf("%s: %s", fmt.Sprintf(format, args...), err)
1371 panic(webapi.Error{Code: "messageNotFound", Message: msg})
1373 xcheckuserf(err, format, args...)
1377func (s server) MessageDelete(ctx context.Context, req webapi.MessageDeleteRequest) (resp webapi.MessageDeleteResult, err error) {
1378 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1379 xops.MessageDelete(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID})
1383func (s server) MessageFlagsAdd(ctx context.Context, req webapi.MessageFlagsAddRequest) (resp webapi.MessageFlagsAddResult, err error) {
1384 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1385 xops.MessageFlagsAdd(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.Flags)
1389func (s server) MessageFlagsRemove(ctx context.Context, req webapi.MessageFlagsRemoveRequest) (resp webapi.MessageFlagsRemoveResult, err error) {
1390 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1391 xops.MessageFlagsClear(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.Flags)
1395func (s server) MessageMove(ctx context.Context, req webapi.MessageMoveRequest) (resp webapi.MessageMoveResult, err error) {
1396 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1397 xops.MessageMove(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.DestMailboxName, 0)