1// Package webapisrv implements the server-side of the webapi.
2package webapisrv
3
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
6// ../webapi/client.go.
7
8import (
9 "bytes"
10 "context"
11 cryptorand "crypto/rand"
12 "encoding/base64"
13 "encoding/json"
14 "errors"
15 "fmt"
16 htmltemplate "html/template"
17 "io"
18 "log/slog"
19 "mime"
20 "mime/multipart"
21 "net/http"
22 "net/textproto"
23 "os"
24 "reflect"
25 "runtime/debug"
26 "slices"
27 "strings"
28 "time"
29
30 "github.com/prometheus/client_golang/prometheus"
31 "github.com/prometheus/client_golang/prometheus/promauto"
32
33 "github.com/mjl-/bstore"
34
35 "github.com/mjl-/mox/dkim"
36 "github.com/mjl-/mox/dns"
37 "github.com/mjl-/mox/message"
38 "github.com/mjl-/mox/metrics"
39 "github.com/mjl-/mox/mlog"
40 "github.com/mjl-/mox/mox-"
41 "github.com/mjl-/mox/moxio"
42 "github.com/mjl-/mox/moxvar"
43 "github.com/mjl-/mox/queue"
44 "github.com/mjl-/mox/smtp"
45 "github.com/mjl-/mox/store"
46 "github.com/mjl-/mox/webapi"
47 "github.com/mjl-/mox/webauth"
48 "github.com/mjl-/mox/webops"
49)
50
51var pkglog = mlog.New("webapi", nil)
52
53var (
54 // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission and ../webapisrv/server.go:/metricSubmission
55 metricSubmission = promauto.NewCounterVec(
56 prometheus.CounterOpts{
57 Name: "mox_webapi_submission_total",
58 Help: "Webapi message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.",
59 },
60 []string{
61 "result",
62 },
63 )
64 metricServerErrors = promauto.NewCounterVec(
65 prometheus.CounterOpts{
66 Name: "mox_webapi_errors_total",
67 Help: "Webapi server errors, known values: dkimsign, submit.",
68 },
69 []string{
70 "error",
71 },
72 )
73 metricResults = promauto.NewCounterVec(
74 prometheus.CounterOpts{
75 Name: "mox_webapi_results_total",
76 Help: "HTTP webapi results by method and result.",
77 },
78 []string{"method", "result"}, // result: "badauth", "ok", or error code
79 )
80 metricDuration = promauto.NewHistogramVec(
81 prometheus.HistogramOpts{
82 Name: "mox_webapi_duration_seconds",
83 Help: "HTTP webhook call duration.",
84 Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 20, 30},
85 },
86 []string{"method"},
87 )
88)
89
90// We pass the request to the handler so the TLS info can be used for
91// the Received header in submitted messages. Most API calls need just the
92// account name.
93type ctxKey string
94
95var requestInfoCtxKey ctxKey = "requestInfo"
96
97type requestInfo struct {
98 Log mlog.Log
99 LoginAddress string
100 Account *store.Account
101 Response http.ResponseWriter // For setting headers for non-JSON responses.
102 Request *http.Request // For Proto and TLS connection state during message submit.
103}
104
105// todo: show a curl invocation on the method pages
106
107var docsMethodTemplate = htmltemplate.Must(htmltemplate.New("method").Parse(`<!doctype html>
108 <head>
109 <meta charset="utf-8" />
110 <meta name="robots" content="noindex,nofollow" />
111 <title>Method {{ .Method }} - WebAPI - Mox</title>
112 <style>
113body, html { padding: 1em; font-size: 16px; }
114* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
115h1, h2, h3, h4 { margin-bottom: 1ex; }
116h1 { font-size: 1.2rem; }
117h2 { font-size: 1.1rem; }
118h3, h4 { font-size: 1rem; }
119ul { padding-left: 1rem; }
120p { margin-bottom: 1em; max-width: 50em; }
121[title] { text-decoration: underline; text-decoration-style: dotted; }
122fieldset { border: 0; }
123textarea { width: 100%; max-width: 50em; }
124 </style>
125 </head>
126 <body>
127 <h1><a href="../">WebAPI</a> - Method {{ .Method }}</h1>
128 <form id="webapicall" method="POST">
129 <fieldset id="webapifieldset">
130 <h2>Request JSON</h2>
131 <div><textarea id="webapirequest" name="request" required rows="20">{{ .Request }}</textarea></div>
132 <br/>
133 <div>
134 <button type="reset">Reset</button>
135 <button type="submit">Call</button>
136 </div>
137 <br/>
138{{ if .ReturnsBytes }}
139 <p>Method has a non-JSON response.</p>
140{{ else }}
141 <h2>Response JSON</h2>
142 <div><textarea id="webapiresponse" rows="20">{{ .Response }}</textarea></div>
143{{ end }}
144 </fieldset>
145 </form>
146 <script>
147window.addEventListener('load', () => {
148 window.webapicall.addEventListener('submit', async (e) => {
149 const stop = () => {
150 e.stopPropagation()
151 e.preventDefault()
152 }
153
154 let req
155 try {
156 req = JSON.parse(window.webapirequest.value)
157 } catch (err) {
158 window.alert('Error parsing request: ' + err.message)
159 stop()
160 return
161 }
162 if (!req) {
163 window.alert('Empty request')
164 stop()
165 return
166 }
167
168 if ({{ .ReturnsBytes }}) {
169 // Just POST to this URL.
170 return
171 }
172
173 stop()
174 // Do call ourselves, get response and put it in the response textarea.
175 window.webapifieldset.disabled = true
176 let data = new window.FormData()
177 data.append("request", window.webapirequest.value)
178 try {
179 const response = await fetch("{{ .Method }}", {body: data, method: "POST"})
180 const text = await response.text()
181 try {
182 window.webapiresponse.value = JSON.stringify(JSON.parse(text), undefined, '\t')
183 } catch (err) {
184 window.webapiresponse.value = text
185 }
186 } catch (err) {
187 window.alert('Error: ' + err.message)
188 } finally {
189 window.webapifieldset.disabled = false
190 }
191 })
192})
193 </script>
194 </body>
195</html>
196`))
197
198var docsIndex []byte
199
200func init() {
201 var methods []string
202 mt := reflect.TypeFor[webapi.Methods]()
203 n := mt.NumMethod()
204 for i := range n {
205 methods = append(methods, mt.Method(i).Name)
206 }
207 docsIndexTmpl := htmltemplate.Must(htmltemplate.New("index").Parse(`<!doctype html>
208<html>
209 <head>
210 <meta charset="utf-8" />
211 <meta name="robots" content="noindex,nofollow" />
212 <title>Webapi - Mox</title>
213 <style>
214body, html { padding: 1em; font-size: 16px; }
215* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
216h1, h2, h3, h4 { margin-bottom: 1ex; }
217h1 { font-size: 1.2rem; }
218h2 { font-size: 1.1rem; }
219h3, h4 { font-size: 1rem; }
220ul { padding-left: 1rem; }
221p { margin-bottom: 1em; max-width: 50em; }
222[title] { text-decoration: underline; text-decoration-style: dotted; }
223fieldset { border: 0; }
224 </style>
225 </head>
226 <body>
227 <h1>Webapi and webhooks</h1>
228 <p>The mox webapi is a simple HTTP/JSON-based API for sending messages and processing incoming messages.</p>
229 <p>Configure webhooks in mox to receive notifications about outgoing delivery event, and/or incoming deliveries of messages.</p>
230 <p>Documentation and examples:</p>
231 <p><a href="{{ .WebapiDocsURL }}">{{ .WebapiDocsURL }}</a></p>
232 <h2>Methods</h2>
233 <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>
234 <ul>
235{{ range $i, $method := .Methods }}
236 <li><a href="{{ $method }}">{{ $method }}</a></li>
237{{ end }}
238 </ul>
239 </body>
240</html>
241`))
242 webapiDocsURL := "https://pkg.go.dev/github.com/mjl-/mox@" + moxvar.VersionBare + "/webapi/"
243 webhookDocsURL := "https://pkg.go.dev/github.com/mjl-/mox@" + moxvar.VersionBare + "/webhook/"
244 indexArgs := struct {
245 WebapiDocsURL string
246 WebhookDocsURL string
247 Methods []string
248 }{webapiDocsURL, webhookDocsURL, methods}
249 var b bytes.Buffer
250 err := docsIndexTmpl.Execute(&b, indexArgs)
251 if err != nil {
252 panic("executing api docs index template: " + err.Error())
253 }
254 docsIndex = b.Bytes()
255
256 mox.NewWebapiHandler = func(maxMsgSize int64, basePath string, isForwarded bool) http.Handler {
257 return NewServer(maxMsgSize, basePath, isForwarded)
258 }
259}
260
261// NewServer returns a new http.Handler for a webapi server.
262func NewServer(maxMsgSize int64, path string, isForwarded bool) http.Handler {
263 return server{maxMsgSize, path, isForwarded}
264}
265
266// server implements the webapi methods.
267type server struct {
268 maxMsgSize int64 // Of outgoing messages.
269 path string // Path webapi is configured under, typically /webapi/, with methods at /webapi/v0/<method>.
270 isForwarded bool // Whether incoming requests are reverse-proxied. Used for getting remote IPs for rate limiting.
271}
272
273var _ webapi.Methods = server{}
274
275// ServeHTTP implements http.Handler.
276func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
277 log := pkglog.WithContext(r.Context()) // Take cid from webserver.
278
279 // Send requests to /webapi/ to /webapi/v0/.
280 if r.URL.Path == "/" {
281 if r.Method != "GET" {
282 http.Error(w, "405 - method not allow", http.StatusMethodNotAllowed)
283 return
284 }
285 http.Redirect(w, r, s.path+"v0/", http.StatusSeeOther)
286 return
287 }
288 // Serve short introduction and list to methods at /webapi/v0/.
289 if r.URL.Path == "/v0/" {
290 w.Header().Set("Content-Type", "text/html; charset=utf-8")
291 w.Write(docsIndex)
292 return
293 }
294
295 // Anything else must be a method endpoint.
296 if !strings.HasPrefix(r.URL.Path, "/v0/") {
297 http.NotFound(w, r)
298 return
299 }
300 fn := r.URL.Path[len("/v0/"):]
301 log = log.With(slog.String("method", fn))
302 rfn := reflect.ValueOf(s).MethodByName(fn)
303 var zero reflect.Value
304 if rfn == zero || rfn.Type().NumIn() != 2 || rfn.Type().NumOut() != 2 {
305 log.Debug("unknown webapi method")
306 http.NotFound(w, r)
307 return
308 }
309
310 // GET on method returns an example request JSON, a button to call the method,
311 // which either fills a textarea with the response (in case of JSON) or posts to
312 // the URL letting the browser handle the response (e.g. raw message or part).
313 if r.Method == "GET" {
314 formatJSON := func(v any) (string, error) {
315 var b bytes.Buffer
316 enc := json.NewEncoder(&b)
317 enc.SetIndent("", "\t")
318 enc.SetEscapeHTML(false)
319 err := enc.Encode(v)
320 return string(b.String()), err
321 }
322
323 req, err := formatJSON(mox.FillExample(nil, reflect.New(rfn.Type().In(1))).Interface())
324 if err != nil {
325 log.Errorx("formatting request as json", err)
326 http.Error(w, "500 - internal server error - marshal request: "+err.Error(), http.StatusInternalServerError)
327 return
328 }
329 // todo: could check for io.ReadCloser, but we don't return other interfaces than that one.
330 returnsBytes := rfn.Type().Out(0).Kind() == reflect.Interface
331 var resp string
332 if !returnsBytes {
333 resp, err = formatJSON(mox.FillExample(nil, reflect.New(rfn.Type().Out(0))).Interface())
334 if err != nil {
335 log.Errorx("formatting response as json", err)
336 http.Error(w, "500 - internal server error - marshal response: "+err.Error(), http.StatusInternalServerError)
337 return
338 }
339 }
340 args := struct {
341 Method string
342 Request string
343 Response string
344 ReturnsBytes bool
345 }{fn, req, resp, returnsBytes}
346 w.Header().Set("Content-Type", "text/html; charset=utf-8")
347 err = docsMethodTemplate.Execute(w, args)
348 log.Check(err, "executing webapi method template")
349 return
350 } else if r.Method != "POST" {
351 http.Error(w, "405 - method not allowed - use get or post", http.StatusMethodNotAllowed)
352 return
353 }
354
355 // Account is available during call, but we close it before we start writing a
356 // response, to prevent slow readers from holding a reference for a long time.
357 var acc *store.Account
358 closeAccount := func() {
359 if acc != nil {
360 err := acc.Close()
361 log.Check(err, "closing account")
362 acc = nil
363 }
364 }
365 defer closeAccount()
366
367 email, password, aok := r.BasicAuth()
368 if !aok {
369 metricResults.WithLabelValues(fn, "badauth").Inc()
370 log.Debug("missing http basic authentication credentials")
371 w.Header().Set("WWW-Authenticate", "Basic realm=webapi")
372 http.Error(w, "401 - unauthorized - use http basic auth with email address as username", http.StatusUnauthorized)
373 return
374 }
375 log = log.With(slog.String("username", email))
376
377 t0 := time.Now()
378
379 // If client IP/network resulted in too many authentication failures, refuse to serve.
380 clientIP := webauth.ClientIP(log, s.isForwarded, r)
381 if clientIP == nil {
382 metricResults.WithLabelValues(fn, "internal").Inc()
383 log.Debug("cannot find remote ip for rate limiter")
384 http.Error(w, "500 - internal server error - cannot find remote ip", http.StatusInternalServerError)
385 return
386 }
387 if !mox.LimiterFailedAuth.CanAdd(clientIP, t0, 1) {
388 metrics.AuthenticationRatelimitedInc("webapi")
389 log.Debug("refusing connection due to many auth failures", slog.Any("clientip", clientIP))
390 http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
391 return
392 }
393
394 writeError := func(err webapi.Error) {
395 closeAccount()
396 metricResults.WithLabelValues(fn, err.Code).Inc()
397
398 if err.Code == "server" {
399 log.Errorx("webapi call result", err, slog.String("resultcode", err.Code))
400 } else {
401 log.Infox("webapi call result", err, slog.String("resultcode", err.Code))
402 }
403
404 w.Header().Set("Content-Type", "application/json; charset=utf-8")
405 w.WriteHeader(http.StatusBadRequest)
406 enc := json.NewEncoder(w)
407 enc.SetEscapeHTML(false)
408 werr := enc.Encode(err)
409 log.Check(werr, "writing error response")
410 }
411
412 // Called for all successful JSON responses, not non-JSON responses.
413 writeResponse := func(resp any) {
414 closeAccount()
415 metricResults.WithLabelValues(fn, "ok").Inc()
416 log.Debug("webapi call result", slog.String("resultcode", "ok"))
417 w.Header().Set("Content-Type", "application/json; charset=utf-8")
418 enc := json.NewEncoder(w)
419 enc.SetEscapeHTML(false)
420 werr := enc.Encode(resp)
421 log.Check(werr, "writing error response")
422 }
423
424 la := loginAttempt(clientIP.String(), r, "webapi", "httpbasic")
425 la.LoginAddress = email
426 defer func() {
427 store.LoginAttemptAdd(context.Background(), log, la)
428 metricDuration.WithLabelValues(fn).Observe(float64(time.Since(t0)) / float64(time.Second))
429 }()
430
431 var err error
432 acc, la.AccountName, err = store.OpenEmailAuth(log, email, password, true)
433 if err != nil {
434 mox.LimiterFailedAuth.Add(clientIP, t0, 1)
435 if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) || errors.Is(err, store.ErrLoginDisabled) {
436 log.Debug("bad http basic authentication credentials")
437 metricResults.WithLabelValues(fn, "badauth").Inc()
438 la.Result = store.AuthBadCredentials
439 msg := "use http basic auth with email address as username"
440 if errors.Is(err, store.ErrLoginDisabled) {
441 la.Result = store.AuthLoginDisabled
442 msg = "login is disabled for this account"
443 }
444 w.Header().Set("WWW-Authenticate", "Basic realm=webapi")
445 http.Error(w, "401 - unauthorized - "+msg, http.StatusUnauthorized)
446 return
447 }
448 writeError(webapi.Error{Code: "server", Message: "error verifying credentials"})
449 return
450 }
451 la.AccountName = acc.Name
452 la.Result = store.AuthSuccess
453 mox.LimiterFailedAuth.Reset(clientIP, t0)
454
455 ct := r.Header.Get("Content-Type")
456 ct, _, err = mime.ParseMediaType(ct)
457 if err != nil {
458 writeError(webapi.Error{Code: "protocol", Message: "unknown content-type " + r.Header.Get("Content-Type")})
459 return
460 }
461 if ct == "multipart/form-data" {
462 err = r.ParseMultipartForm(200 * 1024)
463 } else {
464 err = r.ParseForm()
465 }
466 if err != nil {
467 writeError(webapi.Error{Code: "protocol", Message: "parsing form: " + err.Error()})
468 return
469 }
470
471 reqstr := r.PostFormValue("request")
472 if reqstr == "" {
473 writeError(webapi.Error{Code: "protocol", Message: "missing/empty request"})
474 return
475 }
476
477 defer func() {
478 x := recover()
479 if x == nil {
480 return
481 }
482 if err, eok := x.(webapi.Error); eok {
483 writeError(err)
484 return
485 }
486 log.Error("unhandled panic in webapi call", slog.Any("x", x), slog.String("resultcode", "server"))
487 metrics.PanicInc(metrics.Webapi)
488 debug.PrintStack()
489 writeError(webapi.Error{Code: "server", Message: "unhandled error"})
490 }()
491 req := reflect.New(rfn.Type().In(1))
492 dec := json.NewDecoder(strings.NewReader(reqstr))
493 dec.DisallowUnknownFields()
494 if err := dec.Decode(req.Interface()); err != nil {
495 writeError(webapi.Error{Code: "protocol", Message: fmt.Sprintf("parsing request: %s", err)})
496 return
497 }
498
499 reqInfo := requestInfo{log, email, acc, w, r}
500 nctx := context.WithValue(r.Context(), requestInfoCtxKey, reqInfo)
501 resp := rfn.Call([]reflect.Value{reflect.ValueOf(nctx), req.Elem()})
502 if !resp[1].IsZero() {
503 var e webapi.Error
504 err := resp[1].Interface().(error)
505 if x, eok := err.(webapi.Error); eok {
506 e = x
507 } else {
508 e = webapi.Error{Code: "error", Message: err.Error()}
509 }
510 writeError(e)
511 return
512 }
513 rc, ok := resp[0].Interface().(io.ReadCloser)
514 if !ok {
515 rv, _ := mox.FillNil(resp[0])
516 writeResponse(rv.Interface())
517 return
518 }
519 closeAccount()
520 log.Debug("webapi call result", slog.String("resultcode", "ok"))
521 metricResults.WithLabelValues(fn, "ok").Inc()
522 defer func() {
523 err := rc.Close()
524 log.Check(err, "closing readcloser")
525 }()
526 _, err = io.Copy(w, rc)
527 log.Check(err, "writing response to client")
528}
529
530// loginAttempt initializes a store.LoginAttempt, for adding to the store after
531// filling in the results and other details.
532func loginAttempt(clientIP string, r *http.Request, protocol, authMech string) store.LoginAttempt {
533 return store.LoginAttempt{
534 RemoteIP: clientIP,
535 TLS: store.LoginAttemptTLS(r.TLS),
536 Protocol: protocol,
537 AuthMech: authMech,
538 UserAgent: r.UserAgent(),
539 Result: store.AuthError, // Replaced by caller.
540 }
541}
542
543func xcheckf(err error, format string, args ...any) {
544 if err != nil {
545 msg := fmt.Sprintf(format, args...)
546 panic(webapi.Error{Code: "server", Message: fmt.Sprintf("%s: %s", msg, err)})
547 }
548}
549
550func xcheckuserf(err error, format string, args ...any) {
551 if err != nil {
552 msg := fmt.Sprintf(format, args...)
553 panic(webapi.Error{Code: "user", Message: fmt.Sprintf("%s: %s", msg, err)})
554 }
555}
556
557func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
558 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
559 fn(tx)
560 return nil
561 })
562 xcheckf(err, "transaction")
563}
564
565func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
566 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
567 fn(tx)
568 return nil
569 })
570 xcheckf(err, "transaction")
571}
572
573func xcheckcontrol(s string) {
574 for _, c := range s {
575 if c < 0x20 {
576 xcheckuserf(errors.New("control characters not allowed"), "checking header values")
577 }
578 }
579}
580
581func xparseAddress(addr string) smtp.Address {
582 a, err := smtp.ParseAddress(addr)
583 if err != nil {
584 panic(webapi.Error{Code: "badAddress", Message: fmt.Sprintf("parsing address %q: %s", addr, err)})
585 }
586 return a
587}
588
589func xparseAddresses(l []webapi.NameAddress) ([]message.NameAddress, []smtp.Path) {
590 r := make([]message.NameAddress, len(l))
591 paths := make([]smtp.Path, len(l))
592 for i, a := range l {
593 xcheckcontrol(a.Name)
594 addr := xparseAddress(a.Address)
595 r[i] = message.NameAddress{DisplayName: a.Name, Address: addr}
596 paths[i] = addr.Path()
597 }
598 return r, paths
599}
600
601func xrandomID(n int) string {
602 return base64.RawURLEncoding.EncodeToString(xrandom(n))
603}
604
605func xrandom(n int) []byte {
606 buf := make([]byte, n)
607 cryptorand.Read(buf)
608 return buf
609}
610
611func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.SendResult, err error) {
612 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
613
614 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
615 log := reqInfo.Log
616 acc := reqInfo.Account
617
618 m := req.Message
619
620 accConf, _ := acc.Conf()
621
622 if m.Text == "" && m.HTML == "" {
623 return resp, webapi.Error{Code: "missingBody", Message: "at least text or html body required"}
624 }
625
626 if len(m.From) == 0 {
627 m.From = []webapi.NameAddress{{Name: accConf.FullName, Address: reqInfo.LoginAddress}}
628 } else if len(m.From) > 1 {
629 return resp, webapi.Error{Code: "multipleFrom", Message: "multiple from-addresses not allowed"}
630 }
631 froms, fromPaths := xparseAddresses(m.From)
632 from, fromPath := froms[0], fromPaths[0]
633 to, toPaths := xparseAddresses(m.To)
634 cc, ccPaths := xparseAddresses(m.CC)
635 bcc, bccPaths := xparseAddresses(m.BCC)
636
637 recipients := append(append(toPaths, ccPaths...), bccPaths...)
638 addresses := append(append(m.To, m.CC...), m.BCC...)
639
640 // Check if from address is allowed for account.
641 if ok, disabled := mox.AllowMsgFrom(acc.Name, from.Address); disabled {
642 metricSubmission.WithLabelValues("domaindisabled").Inc()
643 return resp, webapi.Error{Code: "domainDisabled", Message: "domain of from-address is temporarily disabled"}
644 } else if !ok {
645 metricSubmission.WithLabelValues("badfrom").Inc()
646 return resp, webapi.Error{Code: "badFrom", Message: "from-address not configured for account"}
647 }
648
649 if len(recipients) == 0 {
650 return resp, webapi.Error{Code: "noRecipients", Message: "no recipients"}
651 }
652
653 // Check outgoing message rate limit.
654 xdbread(ctx, acc, func(tx *bstore.Tx) {
655 msglimit, rcptlimit, err := acc.SendLimitReached(tx, recipients)
656 if msglimit >= 0 {
657 metricSubmission.WithLabelValues("messagelimiterror").Inc()
658 panic(webapi.Error{Code: "messageLimitReached", Message: "outgoing message rate limit reached"})
659 } else if rcptlimit >= 0 {
660 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
661 panic(webapi.Error{Code: "recipientLimitReached", Message: "outgoing new recipient rate limit reached"})
662 }
663 xcheckf(err, "checking send limit")
664 })
665
666 // If we have a non-ascii localpart, we will be sending with smtputf8. We'll go
667 // full utf-8 then.
668 intl := func(l []smtp.Path) bool {
669 for _, p := range l {
670 if p.Localpart.IsInternational() {
671 return true
672 }
673 }
674 return false
675 }
676 smtputf8 := intl([]smtp.Path{fromPath}) || intl(toPaths) || intl(ccPaths) || intl(bccPaths)
677
678 replyTos, replyToPaths := xparseAddresses(m.ReplyTo)
679 for _, rt := range replyToPaths {
680 if rt.Localpart.IsInternational() {
681 smtputf8 = true
682 }
683 }
684
685 // Create file to compose message into.
686 dataFile, err := store.CreateMessageTemp(log, "webapi-submit")
687 xcheckf(err, "creating temporary file for message")
688 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
689
690 // If writing to the message file fails, we abort immediately.
691 xc := message.NewComposer(dataFile, s.maxMsgSize, smtputf8)
692 defer func() {
693 x := recover()
694 if x == nil {
695 return
696 }
697 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
698 panic(webapi.Error{Code: "messageTooLarge", Message: "message too large"})
699 } else if ok && errors.Is(err, message.ErrCompose) {
700 xcheckf(err, "making message")
701 }
702 panic(x)
703 }()
704
705 // Each queued message gets a Received header.
706 // We cannot use VIA, because there is no registered method. We would like to use
707 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
708 // We don't add the IP address of the submitter. Exposing likely not desirable.
709 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
710 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
711 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
712 recvHdrFor := func(rcptTo string) string {
713 recvHdr := &message.HeaderWriter{}
714 // For additional Received-header clauses, see:
715 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
716 // Note: we don't have "via" or "with", there is no registered for webmail.
717 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) // ../rfc/5321:3158
718 if reqInfo.Request.TLS != nil {
719 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
720 }
721 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
722 return recvHdr.String()
723 }
724
725 // Outer message headers.
726 xc.HeaderAddrs("From", []message.NameAddress{from})
727 if len(replyTos) > 0 {
728 xc.HeaderAddrs("Reply-To", replyTos)
729 }
730 xc.HeaderAddrs("To", to)
731 xc.HeaderAddrs("Cc", cc)
732 // We prepend Bcc headers to the message when adding to the Sent mailbox.
733 if m.Subject != "" {
734 xcheckcontrol(m.Subject)
735 xc.Subject(m.Subject)
736 }
737
738 var date time.Time
739 if m.Date != nil {
740 date = *m.Date
741 } else {
742 date = time.Now()
743 }
744 xc.Header("Date", date.Format(message.RFC5322Z))
745
746 if m.MessageID == "" {
747 m.MessageID = fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
748 } else if !strings.HasPrefix(m.MessageID, "<") || !strings.HasSuffix(m.MessageID, ">") {
749 return resp, webapi.Error{Code: "malformedMessageID", Message: "missing <> in message-id"}
750 }
751 xcheckcontrol(m.MessageID)
752 xc.Header("Message-Id", m.MessageID)
753
754 if len(m.References) > 0 {
755 for _, ref := range m.References {
756 xcheckcontrol(ref)
757 // We don't check for <>'s. If caller just puts in what they got, we don't want to
758 // reject the message.
759 }
760 xc.Header("References", strings.Join(m.References, "\r\n\t"))
761 xc.Header("In-Reply-To", m.References[len(m.References)-1])
762 }
763 xc.Header("MIME-Version", "1.0")
764
765 var haveUserAgent bool
766 for _, kv := range req.Headers {
767 xcheckcontrol(kv[0])
768 xcheckcontrol(kv[1])
769 xc.Header(kv[0], kv[1])
770 if strings.EqualFold(kv[0], "User-Agent") || strings.EqualFold(kv[0], "X-Mailer") {
771 haveUserAgent = true
772 }
773 }
774 if !haveUserAgent {
775 xc.Header("User-Agent", "mox/"+moxvar.Version)
776 }
777
778 // Whether we have additional separately alternative/inline/attached file(s).
779 mpf := reqInfo.Request.MultipartForm
780 formAlternative := mpf != nil && len(mpf.File["alternativefile"]) > 0
781 formInline := mpf != nil && len(mpf.File["inlinefile"]) > 0
782 formAttachment := mpf != nil && len(mpf.File["attachedfile"]) > 0
783
784 // MIME structure we'll build:
785 // - multipart/mixed (in case of attached files)
786 // - multipart/related (in case of inline files, we assume they are relevant both text and html part if present)
787 // - multipart/alternative (in case we have both text and html bodies)
788 // - text/plain (optional)
789 // - text/html (optional)
790 // - alternative file, ...
791 // - inline file, ...
792 // - attached file, ...
793
794 // We keep track of cur, which is where we add new parts to, whether the text or
795 // html part, or the inline or attached files.
796 var cur, mixed, related, alternative *multipart.Writer
797 xcreateMultipart := func(subtype string) *multipart.Writer {
798 mp := multipart.NewWriter(xc)
799 if cur == nil {
800 xc.Header("Content-Type", fmt.Sprintf(`multipart/%s; boundary="%s"`, subtype, mp.Boundary()))
801 xc.Line()
802 } else {
803 _, err := cur.CreatePart(textproto.MIMEHeader{"Content-Type": []string{fmt.Sprintf(`multipart/%s; boundary="%s"`, subtype, mp.Boundary())}})
804 xcheckf(err, "adding multipart")
805 }
806 return mp
807 }
808 xcreatePart := func(header textproto.MIMEHeader) io.Writer {
809 if cur == nil {
810 for k, vl := range header {
811 for _, v := range vl {
812 xc.Header(k, v)
813 }
814 }
815 xc.Line()
816 return xc
817 }
818 p, err := cur.CreatePart(header)
819 xcheckf(err, "adding part")
820 return p
821 }
822 // We create multiparts from outer structure to inner. Then for each we add its
823 // inner parts and close the multipart.
824 if len(req.AttachedFiles) > 0 || formAttachment {
825 mixed = xcreateMultipart("mixed")
826 cur = mixed
827 }
828 if len(req.InlineFiles) > 0 || formInline {
829 related = xcreateMultipart("related")
830 cur = related
831 }
832 if m.Text != "" && m.HTML != "" || len(req.AlternativeFiles) > 0 || formAlternative {
833 alternative = xcreateMultipart("alternative")
834 cur = alternative
835 }
836 if m.Text != "" {
837 textBody, ct, cte := xc.TextPart("plain", m.Text)
838 tp := xcreatePart(textproto.MIMEHeader{"Content-Type": []string{ct}, "Content-Transfer-Encoding": []string{cte}})
839 _, err := tp.Write([]byte(textBody))
840 xcheckf(err, "write text part")
841 }
842 if m.HTML != "" {
843 htmlBody, ct, cte := xc.TextPart("html", m.HTML)
844 tp := xcreatePart(textproto.MIMEHeader{"Content-Type": []string{ct}, "Content-Transfer-Encoding": []string{cte}})
845 _, err := tp.Write([]byte(htmlBody))
846 xcheckf(err, "write html part")
847 }
848
849 xaddFileBase64 := func(ct string, inline bool, filename string, cid string, base64Data string) {
850 h := textproto.MIMEHeader{}
851 disp := "attachment"
852 if inline {
853 disp = "inline"
854 }
855 cd := mime.FormatMediaType(disp, map[string]string{"filename": filename})
856
857 h.Set("Content-Type", ct)
858 h.Set("Content-Disposition", cd)
859 if cid != "" {
860 h.Set("Content-ID", cid)
861 }
862 h.Set("Content-Transfer-Encoding", "base64")
863 p := xcreatePart(h)
864
865 for len(base64Data) > 0 {
866 line := base64Data
867 n := min(len(line), 76) // ../rfc/2045:1372
868 line, base64Data = base64Data[:n], base64Data[n:]
869 _, err := p.Write([]byte(line))
870 xcheckf(err, "writing attachment")
871 _, err = p.Write([]byte("\r\n"))
872 xcheckf(err, "writing attachment")
873 }
874 }
875 xaddJSONFiles := func(l []webapi.File, inline bool) {
876 for _, f := range l {
877 if f.ContentType == "" {
878 buf, _ := io.ReadAll(io.LimitReader(base64.NewDecoder(base64.StdEncoding, strings.NewReader(f.Data)), 512))
879 f.ContentType = http.DetectContentType(buf)
880 if f.ContentType == "application/octet-stream" {
881 f.ContentType = ""
882 }
883 }
884
885 // Ensure base64 is valid, then we'll write the original string.
886 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(f.Data)))
887 xcheckuserf(err, "parsing attachment as base64")
888
889 xaddFileBase64(f.ContentType, inline, f.Name, f.ContentID, f.Data)
890 }
891 }
892 xaddFile := func(fh *multipart.FileHeader, inline bool) {
893 f, err := fh.Open()
894 xcheckf(err, "open uploaded file")
895 defer func() {
896 err := f.Close()
897 log.Check(err, "closing uploaded file")
898 }()
899
900 ct := fh.Header.Get("Content-Type")
901 if ct == "" {
902 buf, err := io.ReadAll(io.LimitReader(f, 512))
903 if err == nil {
904 ct = http.DetectContentType(buf)
905 }
906 _, err = f.Seek(0, 0)
907 xcheckf(err, "rewind uploaded file after content-detection")
908 if ct == "application/octet-stream" {
909 ct = ""
910 }
911 }
912
913 h := textproto.MIMEHeader{}
914 disp := "attachment"
915 if inline {
916 disp = "inline"
917 }
918 cd := mime.FormatMediaType(disp, map[string]string{"filename": fh.Filename})
919
920 if ct != "" {
921 h.Set("Content-Type", ct)
922 }
923 h.Set("Content-Disposition", cd)
924 cid := fh.Header.Get("Content-ID")
925 if cid != "" {
926 h.Set("Content-ID", cid)
927 }
928 h.Set("Content-Transfer-Encoding", "base64")
929 p := xcreatePart(h)
930 bw := moxio.Base64Writer(p)
931 _, err = io.Copy(bw, f)
932 xcheckf(err, "adding uploaded file")
933 err = bw.Close()
934 xcheckf(err, "flushing uploaded file")
935 }
936
937 cur = alternative
938 xaddJSONFiles(req.AlternativeFiles, true)
939 if mpf != nil {
940 for _, fh := range mpf.File["alternativefile"] {
941 xaddFile(fh, true)
942 }
943 }
944 if alternative != nil {
945 err := alternative.Close()
946 xcheckf(err, "closing alternative part")
947 alternative = nil
948 }
949
950 cur = related
951 xaddJSONFiles(req.InlineFiles, true)
952 if mpf != nil {
953 for _, fh := range mpf.File["inlinefile"] {
954 xaddFile(fh, true)
955 }
956 }
957 if related != nil {
958 err := related.Close()
959 xcheckf(err, "closing related part")
960 related = nil
961 }
962 cur = mixed
963 xaddJSONFiles(req.AttachedFiles, false)
964 if mpf != nil {
965 for _, fh := range mpf.File["attachedfile"] {
966 xaddFile(fh, false)
967 }
968 }
969 if mixed != nil {
970 err := mixed.Close()
971 xcheckf(err, "closing mixed part")
972 mixed = nil
973 }
974 cur = nil
975 xc.Flush()
976
977 // Add DKIM-Signature headers.
978 var msgPrefix string
979 fd := from.Address.Domain
980 confDom, _ := mox.Conf.Domain(fd)
981 if confDom.Disabled {
982 xcheckuserf(mox.ErrDomainDisabled, "checking domain")
983 }
984 selectors := mox.DKIMSelectors(confDom.DKIM)
985 if len(selectors) > 0 {
986 dkimHeaders, err := dkim.Sign(ctx, log.Logger, from.Address.Localpart, fd, selectors, smtputf8, dataFile)
987 if err != nil {
988 metricServerErrors.WithLabelValues("dkimsign").Inc()
989 }
990 xcheckf(err, "sign dkim")
991
992 msgPrefix = dkimHeaders
993 }
994
995 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
996 xcheckf(err, "parsing login address")
997 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
998 var localpartBase string
999 if useFromID {
1000 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0]
1001 }
1002 fromIDs := make([]string, len(recipients))
1003 qml := make([]queue.Msg, len(recipients))
1004 now := time.Now()
1005 for i, rcpt := range recipients {
1006 fp := fromPath
1007 if useFromID {
1008 fromIDs[i] = xrandomID(16)
1009 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromIDs[i])
1010 }
1011
1012 // Don't use per-recipient unique message prefix when multiple recipients are
1013 // present, we want to keep the message identical.
1014 var recvRcpt string
1015 if len(recipients) == 1 {
1016 recvRcpt = rcpt.XString(smtputf8)
1017 }
1018 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
1019 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
1020 qm := queue.MakeMsg(fp, rcpt, xc.Has8bit, xc.SMTPUTF8, msgSize, m.MessageID, []byte(rcptMsgPrefix), req.RequireTLS, now, m.Subject)
1021 qm.FromID = fromIDs[i]
1022 qm.Extra = req.Extra
1023 if req.FutureRelease != nil {
1024 ival := time.Until(*req.FutureRelease)
1025 if ival > queue.FutureReleaseIntervalMax {
1026 xcheckuserf(fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
1027 }
1028 qm.NextAttempt = *req.FutureRelease
1029 qm.FutureReleaseRequest = "until;" + req.FutureRelease.Format(time.RFC3339)
1030 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
1031 }
1032 qml[i] = qm
1033 }
1034 err = queue.Add(ctx, log, acc.Name, dataFile, qml...)
1035 if err != nil {
1036 metricSubmission.WithLabelValues("queueerror").Inc()
1037 }
1038 xcheckf(err, "adding messages to the delivery queue")
1039 metricSubmission.WithLabelValues("ok").Inc()
1040
1041 // Message has been added to the queue. Ensure we finish the work.
1042 ctx = context.WithoutCancel(ctx)
1043
1044 if req.SaveSent {
1045 // Append message to Sent mailbox and mark original messages as answered/forwarded.
1046 acc.WithRLock(func() {
1047 var changes []store.Change
1048
1049 var sentID int64
1050 metricked := false
1051 defer func() {
1052 if sentID != 0 {
1053 p := acc.MessagePath(sentID)
1054 err := os.Remove(p)
1055 log.Check(err, "removing sent message file after error", slog.String("path", p))
1056 }
1057
1058 if x := recover(); x != nil {
1059 if !metricked {
1060 metricServerErrors.WithLabelValues("submit").Inc()
1061 }
1062 panic(x)
1063 }
1064 }()
1065 xdbwrite(ctx, reqInfo.Account, func(tx *bstore.Tx) {
1066 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1067 if err == bstore.ErrAbsent {
1068 // There is no mailbox designated as Sent mailbox, so we're done.
1069 return
1070 }
1071 xcheckf(err, "message submitted to queue, adding to Sent mailbox")
1072
1073 modseq, err := acc.NextModSeq(tx)
1074 xcheckf(err, "next modseq")
1075
1076 // If there were bcc headers, prepend those to the stored message only, before the
1077 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored message
1078 // won't validate with DKIM anymore, which is fine.
1079 if len(bcc) > 0 {
1080 var sb strings.Builder
1081 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1082 xbcc.HeaderAddrs("Bcc", bcc)
1083 xbcc.Flush()
1084 msgPrefix = sb.String() + msgPrefix
1085 }
1086
1087 sentm := store.Message{
1088 CreateSeq: modseq,
1089 ModSeq: modseq,
1090 MailboxID: sentmb.ID,
1091 MailboxOrigID: sentmb.ID,
1092 Flags: store.Flags{Notjunk: true, Seen: true},
1093 Size: int64(len(msgPrefix)) + xc.Size,
1094 MsgPrefix: []byte(msgPrefix),
1095 }
1096
1097 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1098 if err != nil && errors.Is(err, store.ErrOverQuota) {
1099 panic(webapi.Error{Code: "sentOverQuota", Message: fmt.Sprintf("message was sent, but not stored in sent mailbox: %v", err)})
1100 } else if err != nil {
1101 metricSubmission.WithLabelValues("storesenterror").Inc()
1102 metricked = true
1103 }
1104 xcheckf(err, "message submitted to queue, appending message to Sent mailbox")
1105 sentID = sentm.ID
1106
1107 err = tx.Update(&sentmb)
1108 xcheckf(err, "updating mailbox")
1109
1110 changes = append(changes, sentm.ChangeAddUID(sentmb), sentmb.ChangeCounts())
1111 })
1112 sentID = 0 // Commit.
1113
1114 store.BroadcastChanges(acc, changes)
1115 })
1116 }
1117
1118 submissions := make([]webapi.Submission, len(qml))
1119 for i, qm := range qml {
1120 submissions[i] = webapi.Submission{
1121 Address: addresses[i].Address,
1122 QueueMsgID: qm.ID,
1123 FromID: fromIDs[i],
1124 }
1125 }
1126 resp = webapi.SendResult{
1127 MessageID: m.MessageID,
1128 Submissions: submissions,
1129 }
1130 return resp, nil
1131}
1132
1133func (s server) SuppressionList(ctx context.Context, req webapi.SuppressionListRequest) (resp webapi.SuppressionListResult, err error) {
1134 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1135 resp.Suppressions, err = queue.SuppressionList(ctx, reqInfo.Account.Name)
1136 return
1137}
1138
1139func (s server) SuppressionAdd(ctx context.Context, req webapi.SuppressionAddRequest) (resp webapi.SuppressionAddResult, err error) {
1140 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1141 addr := xparseAddress(req.EmailAddress)
1142 sup := webapi.Suppression{
1143 Account: reqInfo.Account.Name,
1144 Manual: req.Manual,
1145 Reason: req.Reason,
1146 }
1147 err = queue.SuppressionAdd(ctx, addr.Path(), &sup)
1148 return resp, err
1149}
1150
1151func (s server) SuppressionRemove(ctx context.Context, req webapi.SuppressionRemoveRequest) (resp webapi.SuppressionRemoveResult, err error) {
1152 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1153 addr := xparseAddress(req.EmailAddress)
1154 err = queue.SuppressionRemove(ctx, reqInfo.Account.Name, addr.Path())
1155 return resp, err
1156}
1157
1158func (s server) SuppressionPresent(ctx context.Context, req webapi.SuppressionPresentRequest) (resp webapi.SuppressionPresentResult, err error) {
1159 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1160 addr := xparseAddress(req.EmailAddress)
1161 xcheckuserf(err, "parsing address %q", req.EmailAddress)
1162 sup, err := queue.SuppressionLookup(ctx, reqInfo.Account.Name, addr.Path())
1163 if sup != nil {
1164 resp.Present = true
1165 }
1166 return resp, err
1167}
1168
1169func xwebapiAddresses(l []message.Address) (r []webapi.NameAddress) {
1170 r = make([]webapi.NameAddress, len(l))
1171 for i, ma := range l {
1172 dom, err := dns.ParseDomain(ma.Host)
1173 xcheckf(err, "parsing host %q for address", ma.Host)
1174 lp, err := smtp.ParseLocalpart(ma.User)
1175 xcheckf(err, "parsing localpart %q for address", ma.User)
1176 path := smtp.Path{Localpart: lp, IPDomain: dns.IPDomain{Domain: dom}}
1177 r[i] = webapi.NameAddress{Name: ma.Name, Address: path.XString(true)}
1178 }
1179 return r
1180}
1181
1182// caller should hold account lock.
1183func xmessageGet(ctx context.Context, acc *store.Account, msgID int64) (store.Message, store.Mailbox) {
1184 m := store.Message{ID: msgID}
1185 var mb store.Mailbox
1186 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1187 if err := tx.Get(&m); err == bstore.ErrAbsent || err == nil && m.Expunged {
1188 panic(webapi.Error{Code: "messageNotFound", Message: "message not found"})
1189 }
1190 var err error
1191 mb, err = store.MailboxID(tx, m.MailboxID)
1192 if err != nil {
1193 return fmt.Errorf("get mailbox: %v", err)
1194 }
1195 return nil
1196 })
1197 xcheckf(err, "get message")
1198 return m, mb
1199}
1200
1201func (s server) MessageGet(ctx context.Context, req webapi.MessageGetRequest) (resp webapi.MessageGetResult, err error) {
1202 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1203 log := reqInfo.Log
1204 acc := reqInfo.Account
1205
1206 var m store.Message
1207 var mb store.Mailbox
1208 var msgr *store.MsgReader
1209 acc.WithRLock(func() {
1210 m, mb = xmessageGet(ctx, acc, req.MsgID)
1211 msgr = acc.MessageReader(m)
1212 })
1213 defer func() {
1214 if err != nil {
1215 err := msgr.Close()
1216 log.Check(err, "cleaning up message reader")
1217 }
1218 }()
1219
1220 p, err := m.LoadPart(msgr)
1221 xcheckf(err, "load parsed message")
1222
1223 var env message.Envelope
1224 if p.Envelope != nil {
1225 env = *p.Envelope
1226 }
1227 text, html, _, err := webops.ReadableParts(p, 1*1024*1024)
1228 if err != nil {
1229 log.Debugx("looking for text and html content in message", err)
1230 }
1231 date := &env.Date
1232 if date.IsZero() {
1233 date = nil
1234 }
1235
1236 // Parse References message header.
1237 h, err := p.Header()
1238 if err != nil {
1239 log.Debugx("parsing headers for References", err)
1240 }
1241 var refs []string
1242 for _, s := range h.Values("References") {
1243 s = strings.ReplaceAll(s, "\t", " ")
1244 for w := range strings.SplitSeq(s, " ") {
1245 if w != "" {
1246 refs = append(refs, w)
1247 }
1248 }
1249 }
1250 if env.InReplyTo != "" && !slices.Contains(refs, env.InReplyTo) {
1251 // References are ordered, most recent first. In-Reply-To is less powerful/older.
1252 // So if both are present, give References preference, prepending the In-Reply-To
1253 // header.
1254 refs = append([]string{env.InReplyTo}, refs...)
1255 }
1256
1257 msg := webapi.Message{
1258 From: xwebapiAddresses(env.From),
1259 To: xwebapiAddresses(env.To),
1260 CC: xwebapiAddresses(env.CC),
1261 BCC: xwebapiAddresses(env.BCC),
1262 ReplyTo: xwebapiAddresses(env.ReplyTo),
1263 MessageID: env.MessageID,
1264 References: refs,
1265 Date: date,
1266 Subject: env.Subject,
1267 Text: strings.ReplaceAll(text, "\r\n", "\n"),
1268 HTML: strings.ReplaceAll(html, "\r\n", "\n"),
1269 }
1270
1271 var msgFrom string
1272 if d, err := dns.ParseDomain(m.MsgFromDomain); err == nil {
1273 msgFrom = smtp.NewAddress(m.MsgFromLocalpart, d).Pack(true)
1274 }
1275 var rcptTo string
1276 if m.RcptToDomain != "" {
1277 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1278 }
1279 meta := webapi.MessageMeta{
1280 Size: m.Size,
1281 DSN: m.DSN,
1282 Flags: append(m.Flags.Strings(), m.Keywords...),
1283 MailFrom: m.MailFrom,
1284 MailFromValidated: m.MailFromValidated,
1285 RcptTo: rcptTo,
1286 MsgFrom: msgFrom,
1287 MsgFromValidated: m.MsgFromValidated,
1288 DKIMVerifiedDomains: m.DKIMDomains,
1289 RemoteIP: m.RemoteIP,
1290 MailboxName: mb.Name,
1291 }
1292
1293 structure, err := queue.PartStructure(log, &p)
1294 xcheckf(err, "parsing structure")
1295
1296 result := webapi.MessageGetResult{
1297 Message: msg,
1298 Structure: structure,
1299 Meta: meta,
1300 }
1301 return result, nil
1302}
1303
1304func (s server) MessageRawGet(ctx context.Context, req webapi.MessageRawGetRequest) (resp io.ReadCloser, err error) {
1305 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1306 acc := reqInfo.Account
1307
1308 var m store.Message
1309 var msgr *store.MsgReader
1310 acc.WithRLock(func() {
1311 m, _ = xmessageGet(ctx, acc, req.MsgID)
1312 msgr = acc.MessageReader(m)
1313 })
1314
1315 reqInfo.Response.Header().Set("Content-Type", "text/plain")
1316 return msgr, nil
1317}
1318
1319func (s server) MessagePartGet(ctx context.Context, req webapi.MessagePartGetRequest) (resp io.ReadCloser, err error) {
1320 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1321 log := reqInfo.Log
1322 acc := reqInfo.Account
1323
1324 var m store.Message
1325 var msgr *store.MsgReader
1326 acc.WithRLock(func() {
1327 m, _ = xmessageGet(ctx, acc, req.MsgID)
1328 msgr = acc.MessageReader(m)
1329 })
1330 defer func() {
1331 if err != nil {
1332 err := msgr.Close()
1333 log.Check(err, "cleaning up message reader")
1334 }
1335 }()
1336
1337 p, err := m.LoadPart(msgr)
1338 xcheckf(err, "load parsed message")
1339
1340 for i, index := range req.PartPath {
1341 if index < 0 || index >= len(p.Parts) {
1342 return nil, webapi.Error{Code: "partNotFound", Message: fmt.Sprintf("part %d at index %d not found", index, i)}
1343 }
1344 p = p.Parts[index]
1345 }
1346 return struct {
1347 io.Reader
1348 io.Closer
1349 }{Reader: p.Reader(), Closer: msgr}, nil
1350}
1351
1352var xops = webops.XOps{
1353 DBWrite: xdbwrite,
1354 Checkf: func(ctx context.Context, err error, format string, args ...any) {
1355 xcheckf(err, format, args...)
1356 },
1357 Checkuserf: func(ctx context.Context, err error, format string, args ...any) {
1358 if err != nil && errors.Is(err, webops.ErrMessageNotFound) {
1359 msg := fmt.Sprintf("%s: %s", fmt.Sprintf(format, args...), err)
1360 panic(webapi.Error{Code: "messageNotFound", Message: msg})
1361 }
1362 xcheckuserf(err, format, args...)
1363 },
1364}
1365
1366func (s server) MessageDelete(ctx context.Context, req webapi.MessageDeleteRequest) (resp webapi.MessageDeleteResult, err error) {
1367 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1368 xops.MessageDelete(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID})
1369 return
1370}
1371
1372func (s server) MessageFlagsAdd(ctx context.Context, req webapi.MessageFlagsAddRequest) (resp webapi.MessageFlagsAddResult, err error) {
1373 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1374 xops.MessageFlagsAdd(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.Flags)
1375 return
1376}
1377
1378func (s server) MessageFlagsRemove(ctx context.Context, req webapi.MessageFlagsRemoveRequest) (resp webapi.MessageFlagsRemoveResult, err error) {
1379 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1380 xops.MessageFlagsClear(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.Flags)
1381 return
1382}
1383
1384func (s server) MessageMove(ctx context.Context, req webapi.MessageMoveRequest) (resp webapi.MessageMoveResult, err error) {
1385 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1386 xops.MessageMove(ctx, reqInfo.Log, reqInfo.Account, []int64{req.MsgID}, req.DestMailboxName, 0)
1387 return
1388}
1389