1// Package webmail implements a webmail client, serving html/js and providing an API for message actions and SSE endpoint for receiving real-time updates.
4// todo: should we be serving the messages/parts on a separate (sub)domain for user-content? to limit damage if the csp rules aren't enough.
29 "golang.org/x/net/html"
31 "github.com/prometheus/client_golang/prometheus"
32 "github.com/prometheus/client_golang/prometheus/promauto"
34 "github.com/mjl-/bstore"
35 "github.com/mjl-/sherpa"
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/store"
43 "github.com/mjl-/mox/webauth"
44 "github.com/mjl-/mox/webops"
47var pkglog = mlog.New("webmail", nil)
51// We pass the request to the sherpa handler so the TLS info can be used for
52// the Received header in submitted messages. Most API calls need just the
54var requestInfoCtxKey ctxKey = "requestInfo"
56type requestInfo struct {
59 Account *store.Account // Nil only for methods Login and LoginPrep.
60 SessionToken store.SessionToken
61 Response http.ResponseWriter
62 Request *http.Request // For Proto and TLS connection state during message submit.
65//go:embed webmail.html
72var webmailmsgHTML []byte
75var webmailmsgJS []byte
78var webmailtextHTML []byte
81var webmailtextJS []byte
84 // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission and ../webapisrv/server.go:/metricSubmission
85 metricSubmission = promauto.NewCounterVec(
86 prometheus.CounterOpts{
87 Name: "mox_webmail_submission_total",
88 Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror.",
94 metricServerErrors = promauto.NewCounterVec(
95 prometheus.CounterOpts{
96 Name: "mox_webmail_errors_total",
97 Help: "Webmail server errors, known values: dkimsign, submit.",
103 metricSSEConnections = promauto.NewGauge(
104 prometheus.GaugeOpts{
105 Name: "mox_webmail_sse_connections",
106 Help: "Number of active webmail SSE connections.",
111func xcheckf(ctx context.Context, err error, format string, args ...any) {
115 msg := fmt.Sprintf(format, args...)
116 errmsg := fmt.Sprintf("%s: %s", msg, err)
117 pkglog.WithContext(ctx).Errorx(msg, err)
118 code := "server:error"
119 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
122 panic(&sherpa.Error{Code: code, Message: errmsg})
125func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
129 msg := fmt.Sprintf(format, args...)
130 errmsg := fmt.Sprintf("%s: %s", msg, err)
131 pkglog.WithContext(ctx).Errorx(msg, err)
132 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
135func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
136 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
140 xcheckf(ctx, err, "transaction")
143func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
144 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
148 xcheckf(ctx, err, "transaction")
151var webmailFile = &mox.WebappFile{
154 HTMLPath: filepath.FromSlash("webmail/webmail.html"),
155 JSPath: filepath.FromSlash("webmail/webmail.js"),
156 CustomStem: "webmail",
159func customization() (css, js []byte, err error) {
160 if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) {
163 if js, err = os.ReadFile(mox.ConfigDirPath("webmail.js")); err != nil && !errors.Is(err, fs.ErrNotExist) {
166 css = append([]byte("/* Custom CSS by admin from $configdir/webmail.css: */\n"), css...)
167 js = append([]byte("// Custom JS by admin from $configdir/webmail.js:\n"), js...)
168 js = append(js, '\n')
172// Serve HTML content, either from a file, or return the fallback data. If
173// customize is set, css/js is inserted if configured. Caller should already have
174// set the content-type. We use this to return a file from the local file system
175// (during development), or embedded in the binary (when deployed).
176func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte, customize bool) {
177 serve := func(mtime time.Time, rd io.ReadSeeker) {
179 buf, err := io.ReadAll(rd)
181 log.Errorx("reading content to customize", err)
182 http.Error(w, "500 - internal server error - reading content to customize", http.StatusInternalServerError)
185 customCSS, customJS, err := customization()
187 log.Errorx("reading customizations", err)
188 http.Error(w, "500 - internal server error - reading customizations", http.StatusInternalServerError)
191 buf = bytes.Replace(buf, []byte("/* css placeholder */"), customCSS, 1)
192 buf = bytes.Replace(buf, []byte("/* js placeholder */"), customJS, 1)
193 rd = bytes.NewReader(buf)
195 http.ServeContent(w, r, "", mtime, rd)
198 f, err := os.Open(path)
203 serve(st.ModTime(), f)
207 serve(mox.FallbackMtime(log), bytes.NewReader(fallback))
211 mox.NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler {
212 return http.HandlerFunc(Handler(maxMsgSize, basePath, isForwarded, accountPath))
216// Handler returns a handler for the webmail endpoints, customized for the max
217// message size coming from the listener and cookiePath.
218func Handler(maxMessageSize int64, cookiePath string, isForwarded bool, accountPath string) func(w http.ResponseWriter, r *http.Request) {
219 sh, err := makeSherpaHandler(maxMessageSize, cookiePath, isForwarded)
220 return func(w http.ResponseWriter, r *http.Request) {
222 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
225 handle(sh, isForwarded, accountPath, w, r)
229func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w http.ResponseWriter, r *http.Request) {
231 log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
233 // Server-sent event connection, for all initial data (list of mailboxes), list of
234 // messages, and all events afterwards. Authenticated through a single use token in
235 // the query string, which it got from a Token API call.
236 if r.URL.Path == "/events" {
237 serveEvents(ctx, log, accountPath, w, r)
246 err, ok := x.(*sherpa.Error)
248 log.WithContext(ctx).Error("handle panic", slog.Any("err", x))
250 metrics.PanicInc(metrics.Webmailhandle)
253 if strings.HasPrefix(err.Code, "user:") {
254 log.Debugx("webmail user error", err)
255 http.Error(w, "400 - bad request - "+err.Message, http.StatusBadRequest)
257 log.Errorx("webmail server error", err)
258 http.Error(w, "500 - internal server error - "+err.Message, http.StatusInternalServerError)
267 h.Set("X-Frame-Options", "deny")
268 h.Set("Referrer-Policy", "same-origin")
269 webmailFile.Serve(ctx, log, w, r)
271 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
275 case "/licenses.txt":
279 h.Set("Content-Type", "text/plain; charset=utf-8")
282 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
286 case "/msg.js", "/text.js":
289 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
294 path := filepath.Join("webmail", r.URL.Path[1:])
295 var fallback = webmailmsgJS
296 if r.URL.Path == "/text.js" {
297 fallback = webmailtextJS
300 w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
301 serveContentFallback(log, w, r, path, fallback, false)
305 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
306 // Only allow POST for calls, they will not work cross-domain without CORS.
307 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
308 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
312 var loginAddress, accName string
313 var sessionToken store.SessionToken
314 // All other URLs, except the login endpoint require some authentication.
315 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
317 isExport := r.URL.Path == "/export"
318 requireCSRF := isAPI || isExport
319 accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webmail", isForwarded, w, r, isAPI, requireCSRF, isExport)
321 // Response has been written already.
327 var acc *store.Account
329 log = log.With(slog.String("account", accName))
331 acc, err = store.OpenAccount(log, accName)
333 log.Errorx("open account", err)
334 http.Error(w, "500 - internal server error - error opening account", http.StatusInternalServerError)
339 log.Check(err, "closing account")
342 reqInfo := requestInfo{log, loginAddress, acc, sessionToken, w, r}
343 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
344 apiHandler.ServeHTTP(w, r.WithContext(ctx))
348 // We are now expecting the following URLs:
350 // .../msg/<msgid>/{attachments.zip,parsedmessage.js,raw}
351 // .../msg/<msgid>/{,msg}{text,html,htmlexternal}
352 // .../msg/<msgid>/{view,viewtext,download}/<partid>
354 if r.URL.Path == "/export" {
355 webops.Export(log, accName, w, r)
359 if !strings.HasPrefix(r.URL.Path, "/msg/") {
364 t := strings.Split(r.URL.Path[len("/msg/"):], "/")
370 id, err := strconv.ParseInt(t[0], 10, 64)
371 if err != nil || id == 0 {
376 // Many of the requests need either a message or a parsed part. Make it easy to
377 // fetch/prepare and cleanup. We only do all the work when the request seems legit
378 // (valid HTTP route and method).
379 xprepare := func() (acc *store.Account, moreHeaders []string, m store.Message, msgr *store.MsgReader, p message.Part, cleanup func(), ok bool) {
380 if r.Method != "GET" {
381 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
391 log.Check(err, "closing message reader")
396 log.Check(err, "closing account")
403 acc, err = store.OpenAccount(log, accName)
404 xcheckf(ctx, err, "open account")
406 m = store.Message{ID: id}
407 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
408 if err := tx.Get(&m); err != nil {
411 s := store.Settings{ID: 1}
412 if err := tx.Get(&s); err != nil {
413 return fmt.Errorf("get settings for more headers: %v", err)
415 moreHeaders = s.ShowHeaders
418 if err == bstore.ErrAbsent || err == nil && m.Expunged {
422 xcheckf(ctx, err, "get message")
424 msgr = acc.MessageReader(m)
426 p, err = m.LoadPart(msgr)
427 xcheckf(ctx, err, "load parsed message")
431 log.Check(err, "closing message reader")
433 log.Check(err, "closing account")
441 // We set a Content-Security-Policy header that is as strict as possible, depending
442 // on the type of message/part/html/js. We have to be careful because we are
443 // returning data that is coming in from external places. E.g. HTML could contain
444 // javascripts that we don't want to execute, especially not on our domain. We load
445 // resources in an iframe. The CSP policy starts out with default-src 'none' to
446 // disallow loading anything, then start allowing what is safe, such as inlined
447 // datauri images and inline styles. Data can only be loaded when the request is
448 // coming from the same origin (so other sites cannot include resources
449 // (messages/parts)).
451 // We want to load resources in sandbox-mode, causing the page to be loaded as from
452 // a different origin. If sameOrigin is set, we have a looser CSP policy:
453 // allow-same-origin is set so resources are loaded as coming from this same
454 // origin. This is needed for the msg* endpoints that render a message, where we
455 // load the message body in a separate iframe again (with stricter CSP again),
456 // which we need to access for its inner height. If allowSelfScript is also set
457 // (for "msgtext"), the CSP leaves out the sandbox entirely.
459 // If allowExternal is set, we allow loading image, media (audio/video), styles and
460 // fronts from external URLs as well as inline URI's. By default we don't allow any
461 // loading of content, except inlined images (we do that ourselves for images
462 // embedded in the email), and we allow inline styles (which are safely constrained
465 // If allowSelfScript is set, inline scripts and scripts from our origin are
466 // allowed. Used to display a message including header. The header is rendered with
467 // javascript, the content is rendered in a separate iframe with a CSP that doesn't
468 // have allowSelfScript.
469 headers := func(sameOrigin, allowExternal, allowSelfScript, allowSelfImg bool) {
470 // allow-popups is needed to make opening links in new tabs work.
471 sb := "sandbox allow-popups allow-popups-to-escape-sandbox; "
472 if sameOrigin && allowSelfScript {
473 // Sandbox with both allow-same-origin and allow-script would not provide security,
474 // and would give warning in console about that.
476 } else if sameOrigin {
477 sb = "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; "
481 script = "; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'"
485 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:" + script
486 } else if allowSelfImg {
487 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'" + script
489 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'" + script
491 h.Set("Content-Security-Policy", csp)
492 h.Set("X-Frame-Options", "sameorigin") // Duplicate with CSP, but better too much than too little.
493 h.Set("X-Content-Type-Options", "nosniff")
494 h.Set("Referrer-Policy", "no-referrer")
498 case len(t) == 2 && t[1] == "attachments.zip":
499 acc, _, m, msgr, p, cleanup, ok := xprepare()
504 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
505 // note: state is cleared by cleanup
507 mi, err := messageItem(log, m, &state, nil)
508 xcheckf(ctx, err, "parsing message")
510 headers(false, false, false, false)
511 h.Set("Content-Type", "application/zip")
512 h.Set("Cache-Control", "no-store, max-age=0")
513 var subjectSlug string
514 if p.Envelope != nil {
515 s := p.Envelope.Subject
516 s = strings.ToLower(s)
517 s = regexp.MustCompile("[^a-z0-9_.-]").ReplaceAllString(s, "-")
518 s = regexp.MustCompile("--*").ReplaceAllString(s, "-")
519 s = strings.TrimLeft(s, "-")
520 s = strings.TrimRight(s, "-")
526 filename := fmt.Sprintf("email-%d-attachments-%s%s.zip", m.ID, m.Received.Format("20060102-150405"), subjectSlug)
527 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
528 h.Set("Content-Disposition", cd)
530 zw := zip.NewWriter(w)
531 names := map[string]bool{}
532 for _, a := range mi.Attachments {
534 _, name, err := ap.DispositionFilename()
535 if err != nil && errors.Is(err, message.ErrParamEncoding) {
536 log.Debugx("parsing disposition header for filename", err)
538 xcheckf(ctx, err, "reading disposition header")
541 name = filepath.Base(name)
543 mt := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
544 if name == "" || names[name] {
545 ext := filepath.Ext(name)
547 // Handle just a few basic types.
548 extensions := map[string]string{
549 "text/plain": ".txt",
550 "text/html": ".html",
551 "image/jpeg": ".jpg",
554 "application/zip": ".zip",
562 if name != "" && strings.HasSuffix(name, ext) {
563 stem = strings.TrimSuffix(name, ext)
566 for _, index := range a.Path {
567 stem += fmt.Sprintf("-%d", index)
574 name = stem + fmt.Sprintf("-%d", seq) + ext
579 fh := zip.FileHeader{
581 Modified: m.Received,
583 nodeflate := map[string]bool{
584 "application/x-bzip2": true,
585 "application/zip": true,
586 "application/x-zip-compressed": true,
587 "application/gzip": true,
588 "application/x-gzip": true,
589 "application/vnd.rar": true,
590 "application/x-rar-compressed": true,
591 "application/x-7z-compressed": true,
593 // Sniff content-type as well for compressed data.
594 buf := make([]byte, 512)
595 n, _ := io.ReadFull(ap.Reader(), buf)
598 sniffmt = strings.ToLower(http.DetectContentType(buf[:n]))
600 deflate := ap.MediaType != "VIDEO" && ap.MediaType != "AUDIO" && (ap.MediaType != "IMAGE" || ap.MediaSubType == "BMP") && !nodeflate[mt] && !nodeflate[sniffmt]
602 fh.Method = zip.Deflate
604 // We cannot return errors anymore: we have already sent an application/zip header.
605 if zf, err := zw.CreateHeader(&fh); err != nil {
606 log.Check(err, "adding to zip file")
608 } else if _, err := io.Copy(zf, ap.Reader()); err != nil {
609 log.Check(err, "writing to zip file")
614 log.Check(err, "final write to zip file")
616 // Raw display of a message, as text/plain.
617 case len(t) == 2 && t[1] == "raw":
618 _, _, _, msgr, p, cleanup, ok := xprepare()
624 // We intentially use text/plain. We certainly don't want to return a format that
625 // browsers or users would think of executing. We do set the charset if available
626 // on the outer part. If present, we assume it may be relevant for other parts. If
627 // not, there is not much we could do better...
628 headers(false, false, false, false)
630 params := map[string]string{}
631 if charset := p.ContentTypeParams["charset"]; charset != "" {
632 params["charset"] = charset
634 h.Set("Content-Type", mime.FormatMediaType(ct, params))
635 h.Set("Cache-Control", "no-store, max-age=0")
637 _, err := io.Copy(w, &moxio.AtReader{R: msgr})
638 log.Check(err, "writing raw")
640 case len(t) == 2 && (t[1] == "msgtext" || t[1] == "msghtml" || t[1] == "msghtmlexternal"):
641 // msg.html has a javascript tag with message data, and javascript to render the
642 // message header like the regular webmail.html and to load the message body in a
643 // separate iframe with a separate request with stronger CSP.
644 acc, _, m, msgr, p, cleanup, ok := xprepare()
650 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
651 // note: state is cleared by cleanup
653 pm, err := parsedMessage(log, m, &state, true, true, true)
654 xcheckf(ctx, err, "getting parsed message")
655 if t[1] == "msgtext" && len(pm.Texts) == 0 || t[1] != "msgtext" && !pm.HasHTML {
656 http.Error(w, "400 - bad request - no such part", http.StatusBadRequest)
661 loadExternal := t[1] == "msghtmlexternal"
662 allowSelfScript := true
663 headers(sameorigin, loadExternal, allowSelfScript, false)
664 h.Set("Content-Type", "text/html; charset=utf-8")
665 h.Set("Cache-Control", "no-store, max-age=0")
667 path := filepath.FromSlash("webmail/msg.html")
668 fallback := webmailmsgHTML
669 serveContentFallback(log, w, r, path, fallback, true)
671 case len(t) == 2 && t[1] == "parsedmessage.js":
672 // Used by msg.html, for the msg* endpoints, for the data needed to show all data
673 // except the message body.
674 // This is js with data inside instead so we can load it synchronously, which we do
675 // to get a "loaded" event after the page was actually loaded.
677 acc, moreHeaders, m, msgr, p, cleanup, ok := xprepare()
682 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
683 // note: state is cleared by cleanup
685 pm, err := parsedMessage(log, m, &state, true, true, true)
686 xcheckf(ctx, err, "parsing parsedmessage")
687 pmjson, err := json.Marshal(pm)
688 xcheckf(ctx, err, "marshal parsedmessage")
692 hl := messageItemMoreHeaders(moreHeaders, pm)
693 mi := MessageItem{m, pm.envelope, pm.attachments, pm.isSigned, pm.isEncrypted, pm.firstLine, false, hl}
694 mijson, err := json.Marshal(mi)
695 xcheckf(ctx, err, "marshal messageitem")
697 headers(false, false, false, false)
698 h.Set("Content-Type", "application/javascript; charset=utf-8")
699 h.Set("Cache-Control", "no-store, max-age=0")
701 _, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson)
702 log.Check(err, "writing parsedmessage.js")
704 case len(t) == 2 && t[1] == "text":
705 // Returns text.html whichs loads the message data with a javascript tag and
706 // renders just the text content with the same code as webmail.html. Used by the
707 // iframe in the msgtext endpoint. Not used by the regular webmail viewer, it
708 // renders the text itself, with the same shared js code.
709 acc, _, m, msgr, p, cleanup, ok := xprepare()
715 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
716 // note: state is cleared by cleanup
718 pm, err := parsedMessage(log, m, &state, true, true, true)
719 xcheckf(ctx, err, "parsing parsedmessage")
721 if len(pm.Texts) == 0 {
722 http.Error(w, "400 - bad request - no text part in message", http.StatusBadRequest)
726 // Needed for inner document height for outer iframe height in separate message view.
728 allowSelfScript := true
730 headers(sameorigin, false, allowSelfScript, allowSelfImg)
731 h.Set("Content-Type", "text/html; charset=utf-8")
732 h.Set("Cache-Control", "no-store, max-age=0")
734 // We typically return the embedded file, but during development it's handy to load
736 path := filepath.FromSlash("webmail/text.html")
737 fallback := webmailtextHTML
738 serveContentFallback(log, w, r, path, fallback, true)
740 case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"):
741 // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri
742 // if the referenced Content-ID attachment can be found.
743 _, _, _, _, p, cleanup, ok := xprepare()
749 setHeaders := func() {
750 // Needed for inner document height for outer iframe height in separate message
751 // view. We only need that when displaying as a separate message on the msghtml*
752 // endpoints. When displaying in the regular webmail, we don't need to know the
753 // inner height so we load it as different origin, which should be safer.
754 sameorigin := r.URL.Query().Get("sameorigin") == "true"
755 allowExternal := strings.HasSuffix(t[1], "external")
756 headers(sameorigin, allowExternal, false, false)
758 h.Set("Content-Type", "text/html; charset=utf-8")
759 h.Set("Cache-Control", "no-store, max-age=0")
762 // todo: skip certain html parts? e.g. with content-disposition: attachment?
764 var usePart func(p *message.Part, parents []*message.Part)
765 usePart = func(p *message.Part, parents []*message.Part) {
769 mt := p.MediaType + "/" + p.MediaSubType
773 err := inlineSanitizeHTML(log, setHeaders, w, p, parents)
775 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
779 parents = append(parents, p)
780 for _, sp := range p.Parts {
781 usePart(&sp, parents)
787 http.Error(w, "400 - bad request - no html part in message", http.StatusBadRequest)
790 case len(t) == 3 && (t[1] == "view" || t[1] == "viewtext" || t[1] == "download"):
791 // View any part, as referenced in the last element path. "0" is the whole message,
792 // 0.0 is the first subpart, etc. "view" returns it with the content-type from the
793 // message (could be dangerous, but we set strict CSP headers), "viewtext" returns
794 // data with a text/plain content-type so the browser will attempt to display it,
795 // and "download" adds a content-disposition header causing the browser the
796 // download the file.
797 _, _, _, _, p, cleanup, ok := xprepare()
803 paths := strings.Split(t[2], ".")
804 if len(paths) == 0 || paths[0] != "0" {
809 for _, e := range paths[1:] {
810 index, err := strconv.ParseInt(e, 10, 32)
811 if err != nil || index < 0 || int(index) >= len(ap.Parts) {
815 ap = ap.Parts[int(index)]
818 headers(false, false, false, false)
820 if t[1] == "viewtext" {
823 ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
825 h.Set("Content-Type", ct)
826 h.Set("Cache-Control", "no-store, max-age=0")
827 if t[1] == "download" {
828 _, name, err := ap.DispositionFilename()
829 if err != nil && errors.Is(err, message.ErrParamEncoding) {
830 log.Debugx("parsing disposition/filename", err)
832 xcheckf(ctx, err, "reading disposition/filename")
835 name = "attachment.bin"
837 cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
838 h.Set("Content-Disposition", cd)
841 _, err := io.Copy(w, ap.Reader())
842 if err != nil && !moxio.IsClosed(err) {
843 log.Errorx("copying attachment", err)
850// inlineSanitizeHTML writes the part as HTML, with "cid:" URIs for html "src"
851// attributes inlined and with potentially dangerous tags removed (javascript). The
852// sanitizing is just a first layer of defense, CSP headers block execution of
853// scripts. If the HTML becomes too large, an error is returned. Before writing
854// HTML, setHeaders is called to write the required headers for content-type and
855// CSP. On error, setHeader is not called, no output is written and the caller
856// should write an error response.
857func inlineSanitizeHTML(log mlog.Log, setHeaders func(), w io.Writer, p *message.Part, parents []*message.Part) error {
858 // Prepare cids if there is a chance we will use them.
859 cids := map[string]*message.Part{}
860 for _, parent := range parents {
861 if parent.MediaType+"/"+parent.MediaSubType == "MULTIPART/RELATED" && p.DecodedSize < 2*1024*1024 {
862 for i, rp := range parent.Parts {
863 if rp.ContentID != "" {
864 cids[strings.ToLower(rp.ContentID)] = &parent.Parts[i]
870 node, err := html.Parse(p.ReaderUTF8OrBinary())
872 return fmt.Errorf("parsing html: %v", err)
875 // We track size, if it becomes too much, we abort and still copy as regular html.
877 if err := inlineNode(node, cids, &totalSize); err != nil {
878 return fmt.Errorf("inline cid uris in html nodes: %w", err)
882 err = html.Render(w, node)
883 log.Check(err, "writing html")
887// We inline cid: URIs into data: URIs. If a cid is missing in the
888// multipart/related, we ignore the error and continue with other HTML nodes. It
889// will probably just result in a "broken image". We limit the max size we
890// generate. We only replace "src" attributes that start with "cid:". A cid URI
891// could theoretically occur in many more places, like link href, and css url().
892// That's probably not common though. Let's wait for someone to need it.
893func inlineNode(node *html.Node, cids map[string]*message.Part, totalSize *int64) error {
894 for i, a := range node.Attr {
895 if a.Key != "src" || !caselessPrefix(a.Val, "cid:") || a.Namespace != "" {
899 ap := cids["<"+strings.ToLower(cid)+">"]
901 // Missing cid, can happen with email, no need to stop returning data.
904 *totalSize += ap.DecodedSize
905 if *totalSize >= 10*1024*1024 {
906 return fmt.Errorf("html too large")
908 var sb strings.Builder
909 if _, err := fmt.Fprintf(&sb, "data:%s;base64,", strings.ToLower(ap.MediaType+"/"+ap.MediaSubType)); err != nil {
910 return fmt.Errorf("writing datauri: %v", err)
912 w := base64.NewEncoder(base64.StdEncoding, &sb)
913 if _, err := io.Copy(w, ap.Reader()); err != nil {
914 return fmt.Errorf("writing base64 datauri: %v", err)
916 node.Attr[i].Val = sb.String()
918 for node = node.FirstChild; node != nil; node = node.NextSibling {
919 if err := inlineNode(node, cids, totalSize); err != nil {
926func caselessPrefix(k, pre string) bool {
927 return len(k) >= len(pre) && strings.EqualFold(k[:len(pre)], pre)
930var targetable = map[string]bool{
937// sanitizeNode removes script elements, on* attributes, javascript: href
938// attributes, adds target="_blank" to all links and to a base tag.
939func sanitizeNode(node *html.Node) {
941 var haveTarget, haveRel bool
942 for i < len(node.Attr) {
944 // Remove dangerous attributes.
945 if strings.HasPrefix(a.Key, "on") || a.Key == "href" && caselessPrefix(a.Val, "javascript:") || a.Key == "src" && caselessPrefix(a.Val, "data:text/html") {
946 copy(node.Attr[i:], node.Attr[i+1:])
947 node.Attr = node.Attr[:len(node.Attr)-1]
950 if a.Key == "target" {
951 node.Attr[i].Val = "_blank"
954 if a.Key == "rel" && targetable[node.Data] {
955 node.Attr[i].Val = "noopener noreferrer"
960 // Ensure target attribute is set for elements that can have it.
961 if !haveTarget && node.Type == html.ElementNode && targetable[node.Data] {
962 node.Attr = append(node.Attr, html.Attribute{Key: "target", Val: "_blank"})
965 if haveTarget && !haveRel {
966 node.Attr = append(node.Attr, html.Attribute{Key: "rel", Val: "noopener noreferrer"})
970 node = node.FirstChild
973 // Set next now, we may remove cur, which clears its NextSibling.
975 node = node.NextSibling
977 // Remove script elements.
978 if cur.Type == html.ElementNode && cur.Data == "script" {
979 parent.RemoveChild(cur)
984 if parent.Type == html.ElementNode && parent.Data == "head" && !haveBase {
985 n := html.Node{Type: html.ElementNode, Data: "base", Attr: []html.Attribute{{Key: "target", Val: "_blank"}, {Key: "rel", Val: "noopener noreferrer"}}}
986 parent.AppendChild(&n)