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.
2package webmail
3
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.
5
6import (
7 "archive/zip"
8 "bytes"
9 "context"
10 "encoding/base64"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "io/fs"
16 "log/slog"
17 "mime"
18 "net/http"
19 "os"
20 "path/filepath"
21 "regexp"
22 "runtime/debug"
23 "slices"
24 "strconv"
25 "strings"
26 "time"
27
28 _ "embed"
29
30 "golang.org/x/net/html"
31
32 "github.com/prometheus/client_golang/prometheus"
33 "github.com/prometheus/client_golang/prometheus/promauto"
34
35 "github.com/mjl-/bstore"
36 "github.com/mjl-/sherpa"
37
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/store"
44 "github.com/mjl-/mox/webauth"
45 "github.com/mjl-/mox/webops"
46)
47
48var pkglog = mlog.New("webmail", nil)
49
50type ctxKey string
51
52// We pass the request to the sherpa handler so the TLS info can be used for
53// the Received header in submitted messages. Most API calls need just the
54// account name.
55var requestInfoCtxKey ctxKey = "requestInfo"
56
57type requestInfo struct {
58 Log mlog.Log
59 LoginAddress string
60 Account *store.Account // Nil only for methods Login and LoginPrep.
61 SessionToken store.SessionToken
62 Response http.ResponseWriter
63 Request *http.Request // For Proto and TLS connection state during message submit.
64}
65
66//go:embed webmail.html
67var webmailHTML []byte
68
69//go:embed webmail.js
70var webmailJS []byte
71
72//go:embed msg.html
73var webmailmsgHTML []byte
74
75//go:embed msg.js
76var webmailmsgJS []byte
77
78//go:embed text.html
79var webmailtextHTML []byte
80
81//go:embed text.js
82var webmailtextJS []byte
83
84var (
85 // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission and ../webapisrv/server.go:/metricSubmission
86 metricSubmission = promauto.NewCounterVec(
87 prometheus.CounterOpts{
88 Name: "mox_webmail_submission_total",
89 Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.",
90 },
91 []string{
92 "result",
93 },
94 )
95 metricServerErrors = promauto.NewCounterVec(
96 prometheus.CounterOpts{
97 Name: "mox_webmail_errors_total",
98 Help: "Webmail server errors, known values: dkimsign, submit.",
99 },
100 []string{
101 "error",
102 },
103 )
104 metricSSEConnections = promauto.NewGauge(
105 prometheus.GaugeOpts{
106 Name: "mox_webmail_sse_connections",
107 Help: "Number of active webmail SSE connections.",
108 },
109 )
110)
111
112func xcheckf(ctx context.Context, err error, format string, args ...any) {
113 if err == nil {
114 return
115 }
116 msg := fmt.Sprintf(format, args...)
117 errmsg := fmt.Sprintf("%s: %s", msg, err)
118 pkglog.WithContext(ctx).Errorx(msg, err)
119 code := "server:error"
120 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
121 code = "user:error"
122 }
123 panic(&sherpa.Error{Code: code, Message: errmsg})
124}
125
126func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
127 if err == nil {
128 return
129 }
130 msg := fmt.Sprintf(format, args...)
131 errmsg := fmt.Sprintf("%s: %s", msg, err)
132 pkglog.WithContext(ctx).Errorx(msg, err)
133 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
134}
135
136func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
137 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
138 fn(tx)
139 return nil
140 })
141 xcheckf(ctx, err, "transaction")
142}
143
144func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
145 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
146 fn(tx)
147 return nil
148 })
149 xcheckf(ctx, err, "transaction")
150}
151
152var webmailFile = &mox.WebappFile{
153 HTML: webmailHTML,
154 JS: webmailJS,
155 HTMLPath: filepath.FromSlash("webmail/webmail.html"),
156 JSPath: filepath.FromSlash("webmail/webmail.js"),
157 CustomStem: "webmail",
158}
159
160func customization() (css, js []byte, err error) {
161 if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) {
162 return nil, nil, err
163 }
164 if js, err = os.ReadFile(mox.ConfigDirPath("webmail.js")); err != nil && !errors.Is(err, fs.ErrNotExist) {
165 return nil, nil, err
166 }
167 css = append([]byte("/* Custom CSS by admin from $configdir/webmail.css: */\n"), css...)
168 js = append([]byte("// Custom JS by admin from $configdir/webmail.js:\n"), js...)
169 js = append(js, '\n')
170 return css, js, nil
171}
172
173// Serve HTML content, either from a file, or return the fallback data. If
174// customize is set, css/js is inserted if configured. Caller should already have
175// set the content-type. We use this to return a file from the local file system
176// (during development), or embedded in the binary (when deployed).
177func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte, customize bool) {
178 serve := func(mtime time.Time, rd io.ReadSeeker) {
179 if customize {
180 buf, err := io.ReadAll(rd)
181 if err != nil {
182 log.Errorx("reading content to customize", err)
183 http.Error(w, "500 - internal server error - reading content to customize", http.StatusInternalServerError)
184 return
185 }
186 customCSS, customJS, err := customization()
187 if err != nil {
188 log.Errorx("reading customizations", err)
189 http.Error(w, "500 - internal server error - reading customizations", http.StatusInternalServerError)
190 return
191 }
192 buf = bytes.Replace(buf, []byte("/* css placeholder */"), customCSS, 1)
193 buf = bytes.Replace(buf, []byte("/* js placeholder */"), customJS, 1)
194 rd = bytes.NewReader(buf)
195 }
196 http.ServeContent(w, r, "", mtime, rd)
197 }
198
199 f, err := os.Open(path)
200 if err == nil {
201 defer func() {
202 err := f.Close()
203 log.Check(err, "closing serve file")
204 }()
205 st, err := f.Stat()
206 if err == nil {
207 serve(st.ModTime(), f)
208 return
209 }
210 }
211 serve(mox.FallbackMtime(log), bytes.NewReader(fallback))
212}
213
214func init() {
215 mox.NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler {
216 return http.HandlerFunc(Handler(maxMsgSize, basePath, isForwarded, accountPath))
217 }
218}
219
220// Handler returns a handler for the webmail endpoints, customized for the max
221// message size coming from the listener and cookiePath.
222func Handler(maxMessageSize int64, cookiePath string, isForwarded bool, accountPath string) func(w http.ResponseWriter, r *http.Request) {
223 sh, err := makeSherpaHandler(maxMessageSize, cookiePath, isForwarded)
224 return func(w http.ResponseWriter, r *http.Request) {
225 if err != nil {
226 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
227 return
228 }
229 handle(sh, isForwarded, accountPath, w, r)
230 }
231}
232
233func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w http.ResponseWriter, r *http.Request) {
234 ctx := r.Context()
235 log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
236
237 // Server-sent event connection, for all initial data (list of mailboxes), list of
238 // messages, and all events afterwards. Authenticated through a single use token in
239 // the query string, which it got from a Token API call.
240 if r.URL.Path == "/events" {
241 serveEvents(ctx, log, accountPath, w, r)
242 return
243 }
244
245 defer func() {
246 x := recover()
247 if x == nil {
248 return
249 }
250 err, ok := x.(*sherpa.Error)
251 if !ok {
252 log.WithContext(ctx).Error("handle panic", slog.Any("err", x))
253 debug.PrintStack()
254 metrics.PanicInc(metrics.Webmailhandle)
255 panic(x)
256 }
257 if strings.HasPrefix(err.Code, "user:") {
258 log.Debugx("webmail user error", err)
259 http.Error(w, "400 - bad request - "+err.Message, http.StatusBadRequest)
260 } else {
261 log.Errorx("webmail server error", err)
262 http.Error(w, "500 - internal server error - "+err.Message, http.StatusInternalServerError)
263 }
264 }()
265
266 switch r.URL.Path {
267 case "/":
268 switch r.Method {
269 case "GET", "HEAD":
270 h := w.Header()
271 h.Set("X-Frame-Options", "deny")
272 h.Set("Referrer-Policy", "same-origin")
273 webmailFile.Serve(ctx, log, w, r)
274 default:
275 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
276 }
277 return
278
279 case "/licenses.txt":
280 switch r.Method {
281 case "GET", "HEAD":
282 h := w.Header()
283 h.Set("Content-Type", "text/plain; charset=utf-8")
284 mox.LicensesWrite(w)
285 default:
286 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
287 }
288 return
289
290 case "/msg.js", "/text.js":
291 switch r.Method {
292 default:
293 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
294 return
295 case "GET", "HEAD":
296 }
297
298 path := filepath.Join("webmail", r.URL.Path[1:])
299 var fallback = webmailmsgJS
300 if r.URL.Path == "/text.js" {
301 fallback = webmailtextJS
302 }
303
304 w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
305 serveContentFallback(log, w, r, path, fallback, false)
306 return
307 }
308
309 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
310 // Only allow POST for calls, they will not work cross-domain without CORS.
311 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
312 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
313 return
314 }
315
316 var loginAddress, accName string
317 var sessionToken store.SessionToken
318 // All other URLs, except the login endpoint require some authentication.
319 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
320 var ok bool
321 isExport := r.URL.Path == "/export"
322 requireCSRF := isAPI || isExport
323 accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webmail", isForwarded, w, r, isAPI, requireCSRF, isExport)
324 if !ok {
325 // Response has been written already.
326 return
327 }
328 }
329
330 if isAPI {
331 var acc *store.Account
332 if accName != "" {
333 log = log.With(slog.String("account", accName))
334 var err error
335 acc, err = store.OpenAccount(log, accName, true)
336 if err != nil {
337 log.Errorx("open account", err)
338 http.Error(w, "500 - internal server error - error opening account", http.StatusInternalServerError)
339 return
340 }
341 defer func() {
342 err := acc.Close()
343 log.Check(err, "closing account")
344 }()
345 }
346 reqInfo := requestInfo{log, loginAddress, acc, sessionToken, w, r}
347 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
348 apiHandler.ServeHTTP(w, r.WithContext(ctx))
349 return
350 }
351
352 // We are now expecting the following URLs:
353 // .../export
354 // .../msg/<msgid>/{attachments.zip,parsedmessage.js,raw}
355 // .../msg/<msgid>/{,msg}{text,html,htmlexternal}
356 // .../msg/<msgid>/{view,viewtext,download}/<partid>
357
358 if r.URL.Path == "/export" {
359 webops.Export(log, accName, w, r)
360 return
361 }
362
363 if !strings.HasPrefix(r.URL.Path, "/msg/") {
364 http.NotFound(w, r)
365 return
366 }
367
368 t := strings.Split(r.URL.Path[len("/msg/"):], "/")
369 if len(t) < 2 {
370 http.NotFound(w, r)
371 return
372 }
373
374 id, err := strconv.ParseInt(t[0], 10, 64)
375 if err != nil || id == 0 {
376 http.NotFound(w, r)
377 return
378 }
379
380 // Many of the requests need either a message or a parsed part. Make it easy to
381 // fetch/prepare and cleanup. We only do all the work when the request seems legit
382 // (valid HTTP route and method).
383 xprepare := func() (acc *store.Account, moreHeaders []string, m store.Message, msgr *store.MsgReader, p message.Part, cleanup func(), ok bool) {
384 if r.Method != "GET" {
385 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
386 return
387 }
388
389 defer func() {
390 if ok {
391 return
392 }
393 if msgr != nil {
394 err := msgr.Close()
395 log.Check(err, "closing message reader")
396 msgr = nil
397 }
398 if acc != nil {
399 err := acc.Close()
400 log.Check(err, "closing account")
401 acc = nil
402 }
403 }()
404
405 var err error
406
407 acc, err = store.OpenAccount(log, accName, false)
408 xcheckf(ctx, err, "open account")
409
410 m = store.Message{ID: id}
411 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
412 if err := tx.Get(&m); err != nil {
413 return err
414 } else if m.Expunged {
415 return fmt.Errorf("message was removed")
416 }
417 s := store.Settings{ID: 1}
418 if err := tx.Get(&s); err != nil {
419 return fmt.Errorf("get settings for more headers: %v", err)
420 }
421 moreHeaders = s.ShowHeaders
422 return nil
423 })
424 if err == bstore.ErrAbsent || err == nil && m.Expunged {
425 http.NotFound(w, r)
426 return
427 }
428 xcheckf(ctx, err, "get message")
429
430 msgr = acc.MessageReader(m)
431
432 p, err = m.LoadPart(msgr)
433 xcheckf(ctx, err, "load parsed message")
434
435 cleanup = func() {
436 err := msgr.Close()
437 log.Check(err, "closing message reader")
438 err = acc.Close()
439 log.Check(err, "closing account")
440 }
441 ok = true
442 return
443 }
444
445 h := w.Header()
446
447 // We set a Content-Security-Policy header that is as strict as possible, depending
448 // on the type of message/part/html/js. We have to be careful because we are
449 // returning data that is coming in from external places. E.g. HTML could contain
450 // javascripts that we don't want to execute, especially not on our domain. We load
451 // resources in an iframe. The CSP policy starts out with default-src 'none' to
452 // disallow loading anything, then start allowing what is safe, such as inlined
453 // datauri images and inline styles. Data can only be loaded when the request is
454 // coming from the same origin (so other sites cannot include resources
455 // (messages/parts)).
456 //
457 // We want to load resources in sandbox-mode, causing the page to be loaded as from
458 // a different origin. If sameOrigin is set, we have a looser CSP policy:
459 // allow-same-origin is set so resources are loaded as coming from this same
460 // origin. This is needed for the msg* endpoints that render a message, where we
461 // load the message body in a separate iframe again (with stricter CSP again),
462 // which we need to access for its inner height. If allowSelfScript is also set
463 // (for "msgtext"), the CSP leaves out the sandbox entirely.
464 //
465 // If allowExternal is set, we allow loading image, media (audio/video), styles and
466 // fronts from external URLs as well as inline URI's. By default we don't allow any
467 // loading of content, except inlined images (we do that ourselves for images
468 // embedded in the email), and we allow inline styles (which are safely constrained
469 // to an iframe).
470 //
471 // If allowSelfScript is set, inline scripts and scripts from our origin are
472 // allowed. Used to display a message including header. The header is rendered with
473 // javascript, the content is rendered in a separate iframe with a CSP that doesn't
474 // have allowSelfScript.
475 headers := func(sameOrigin, allowExternal, allowSelfScript, allowSelfImg bool) {
476 // allow-popups is needed to make opening links in new tabs work.
477 sb := "sandbox allow-popups allow-popups-to-escape-sandbox; "
478 if sameOrigin && allowSelfScript {
479 // Sandbox with both allow-same-origin and allow-script would not provide security,
480 // and would give warning in console about that.
481 sb = ""
482 } else if sameOrigin {
483 sb = "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; "
484 }
485 script := ""
486 if allowSelfScript {
487 script = "; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'"
488 }
489 var csp string
490 if allowExternal {
491 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
492 } else if allowSelfImg {
493 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'" + script
494 } else {
495 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'" + script
496 }
497 h.Set("Content-Security-Policy", csp)
498 h.Set("X-Frame-Options", "sameorigin") // Duplicate with CSP, but better too much than too little.
499 h.Set("X-Content-Type-Options", "nosniff")
500 h.Set("Referrer-Policy", "no-referrer")
501 }
502
503 switch {
504 case len(t) == 2 && t[1] == "attachments.zip":
505 acc, _, m, msgr, p, cleanup, ok := xprepare()
506 if !ok {
507 return
508 }
509 defer cleanup()
510 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
511 // note: state is cleared by cleanup
512
513 mi, err := messageItem(log, m, &state, nil)
514 xcheckf(ctx, err, "parsing message")
515
516 headers(false, false, false, false)
517 h.Set("Content-Type", "application/zip")
518 h.Set("Cache-Control", "no-store, max-age=0")
519 var subjectSlug string
520 if p.Envelope != nil {
521 s := p.Envelope.Subject
522 s = strings.ToLower(s)
523 s = regexp.MustCompile("[^a-z0-9_.-]").ReplaceAllString(s, "-")
524 s = regexp.MustCompile("--*").ReplaceAllString(s, "-")
525 s = strings.TrimLeft(s, "-")
526 s = strings.TrimRight(s, "-")
527 if s != "" {
528 s = "-" + s
529 }
530 subjectSlug = s
531 }
532 filename := fmt.Sprintf("email-%d-attachments-%s%s.zip", m.ID, m.Received.Format("20060102-150405"), subjectSlug)
533 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
534 h.Set("Content-Disposition", cd)
535
536 zw := zip.NewWriter(w)
537 names := map[string]bool{}
538 for _, a := range mi.Attachments {
539 ap := a.Part
540 _, name, err := ap.DispositionFilename()
541 if err != nil && errors.Is(err, message.ErrParamEncoding) {
542 log.Debugx("parsing disposition header for filename", err)
543 } else {
544 xcheckf(ctx, err, "reading disposition header")
545 }
546 if name != "" {
547 name = filepath.Base(name)
548 }
549 mt := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
550 if name == "" || names[name] {
551 ext := filepath.Ext(name)
552 if ext == "" {
553 // Handle just a few basic types.
554 extensions := map[string]string{
555 "text/plain": ".txt",
556 "text/html": ".html",
557 "image/jpeg": ".jpg",
558 "image/png": ".png",
559 "image/gif": ".gif",
560 "application/zip": ".zip",
561 }
562 ext = extensions[mt]
563 if ext == "" {
564 ext = ".bin"
565 }
566 }
567 var stem string
568 if name != "" && strings.HasSuffix(name, ext) {
569 stem = strings.TrimSuffix(name, ext)
570 } else {
571 stem = "attachment"
572 for _, index := range a.Path {
573 stem += fmt.Sprintf("-%d", index)
574 }
575 }
576 name = stem + ext
577 seq := 0
578 for names[name] {
579 seq++
580 name = stem + fmt.Sprintf("-%d", seq) + ext
581 }
582 }
583 names[name] = true
584
585 fh := zip.FileHeader{
586 Name: name,
587 Modified: m.Received,
588 }
589 nodeflate := map[string]bool{
590 "application/x-bzip2": true,
591 "application/zip": true,
592 "application/x-zip-compressed": true,
593 "application/gzip": true,
594 "application/x-gzip": true,
595 "application/vnd.rar": true,
596 "application/x-rar-compressed": true,
597 "application/x-7z-compressed": true,
598 }
599 // Sniff content-type as well for compressed data.
600 buf := make([]byte, 512)
601 n, _ := io.ReadFull(ap.Reader(), buf)
602 var sniffmt string
603 if n > 0 {
604 sniffmt = strings.ToLower(http.DetectContentType(buf[:n]))
605 }
606 deflate := ap.MediaType != "VIDEO" && ap.MediaType != "AUDIO" && (ap.MediaType != "IMAGE" || ap.MediaSubType == "BMP") && !nodeflate[mt] && !nodeflate[sniffmt]
607 if deflate {
608 fh.Method = zip.Deflate
609 }
610 // We cannot return errors anymore: we have already sent an application/zip header.
611 if zf, err := zw.CreateHeader(&fh); err != nil {
612 log.Check(err, "adding to zip file")
613 return
614 } else if _, err := io.Copy(zf, ap.Reader()); err != nil {
615 log.Check(err, "writing to zip file")
616 return
617 }
618 }
619 err = zw.Close()
620 log.Check(err, "final write to zip file")
621
622 // Raw display or download of a message, as text/plain.
623 case len(t) == 2 && (t[1] == "raw" || t[1] == "rawdl"):
624 _, _, m, msgr, p, cleanup, ok := xprepare()
625 if !ok {
626 return
627 }
628 defer cleanup()
629
630 headers(false, false, false, false)
631
632 // We intentially use text/plain. We certainly don't want to return a format that
633 // browsers or users would think of executing. We do set the charset if available
634 // on the outer part. If present, we assume it may be relevant for other parts. If
635 // not, there is not much we could do better...
636 ct := "text/plain"
637 params := map[string]string{}
638
639 if t[1] == "rawdl" {
640 ct = "message/rfc822"
641 if smtputf8, err := p.NeedsSMTPUTF8(); err != nil {
642 log.Errorx("checking for smtputf8 for content-type", err, slog.Int64("msgid", m.ID))
643 http.Error(w, "500 - server error - checking message for content-type: "+err.Error(), http.StatusInternalServerError)
644 return
645 } else if smtputf8 {
646 ct = "message/global"
647 params["charset"] = "utf-8"
648 }
649 } else if charset := p.ContentTypeParams["charset"]; charset != "" {
650 params["charset"] = charset
651 }
652 h.Set("Content-Type", mime.FormatMediaType(ct, params))
653 if t[1] == "rawdl" {
654 filename := fmt.Sprintf("email-%d-%s.eml", m.ID, m.Received.Format("20060102-150405"))
655 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
656 h.Set("Content-Disposition", cd)
657 }
658 h.Set("Cache-Control", "no-store, max-age=0")
659
660 _, err := io.Copy(w, &moxio.AtReader{R: msgr})
661 log.Check(err, "writing raw")
662
663 case len(t) == 2 && (t[1] == "msgtext" || t[1] == "msghtml" || t[1] == "msghtmlexternal"):
664 // msg.html has a javascript tag with message data, and javascript to render the
665 // message header like the regular webmail.html and to load the message body in a
666 // separate iframe with a separate request with stronger CSP.
667 acc, _, m, msgr, p, cleanup, ok := xprepare()
668 if !ok {
669 return
670 }
671 defer cleanup()
672
673 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
674 // note: state is cleared by cleanup
675
676 pm, err := parsedMessage(log, &m, &state, true, true, true)
677 xcheckf(ctx, err, "getting parsed message")
678 if t[1] == "msgtext" && len(pm.Texts) == 0 || t[1] != "msgtext" && !pm.HasHTML {
679 http.Error(w, "400 - bad request - no such part", http.StatusBadRequest)
680 return
681 }
682
683 sameorigin := true
684 loadExternal := t[1] == "msghtmlexternal"
685 allowSelfScript := true
686 headers(sameorigin, loadExternal, allowSelfScript, false)
687 h.Set("Content-Type", "text/html; charset=utf-8")
688 h.Set("Cache-Control", "no-store, max-age=0")
689
690 path := filepath.FromSlash("webmail/msg.html")
691 fallback := webmailmsgHTML
692 serveContentFallback(log, w, r, path, fallback, true)
693
694 case len(t) == 2 && t[1] == "parsedmessage.js":
695 // Used by msg.html, for the msg* endpoints, for the data needed to show all data
696 // except the message body.
697 // This is js with data inside instead so we can load it synchronously, which we do
698 // to get a "loaded" event after the page was actually loaded.
699
700 acc, moreHeaders, m, msgr, p, cleanup, ok := xprepare()
701 if !ok {
702 return
703 }
704 defer cleanup()
705 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
706 // note: state is cleared by cleanup
707
708 pm, err := parsedMessage(log, &m, &state, true, true, true)
709 xcheckf(ctx, err, "parsing parsedmessage")
710 pmjson, err := json.Marshal(pm)
711 xcheckf(ctx, err, "marshal parsedmessage")
712
713 m.MsgPrefix = nil
714 m.ParsedBuf = nil
715 hl := messageItemMoreHeaders(moreHeaders, pm)
716 mi := MessageItem{m, pm.envelope, pm.attachments, pm.isSigned, pm.isEncrypted, false, hl}
717 mijson, err := json.Marshal(mi)
718 xcheckf(ctx, err, "marshal messageitem")
719
720 headers(false, false, false, false)
721 h.Set("Content-Type", "application/javascript; charset=utf-8")
722 h.Set("Cache-Control", "no-store, max-age=0")
723
724 _, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson)
725 log.Check(err, "writing parsedmessage.js")
726
727 case len(t) == 2 && t[1] == "text":
728 // Returns text.html whichs loads the message data with a javascript tag and
729 // renders just the text content with the same code as webmail.html. Used by the
730 // iframe in the msgtext endpoint. Not used by the regular webmail viewer, it
731 // renders the text itself, with the same shared js code.
732 acc, _, m, msgr, p, cleanup, ok := xprepare()
733 if !ok {
734 return
735 }
736 defer cleanup()
737
738 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
739 // note: state is cleared by cleanup
740
741 pm, err := parsedMessage(log, &m, &state, true, true, true)
742 xcheckf(ctx, err, "parsing parsedmessage")
743
744 if len(pm.Texts) == 0 {
745 http.Error(w, "400 - bad request - no text part in message", http.StatusBadRequest)
746 return
747 }
748
749 // Needed for inner document height for outer iframe height in separate message view.
750 sameorigin := true
751 allowSelfScript := true
752 allowSelfImg := true
753 headers(sameorigin, false, allowSelfScript, allowSelfImg)
754 h.Set("Content-Type", "text/html; charset=utf-8")
755 h.Set("Cache-Control", "no-store, max-age=0")
756
757 // We typically return the embedded file, but during development it's handy to load
758 // from disk.
759 path := filepath.FromSlash("webmail/text.html")
760 fallback := webmailtextHTML
761 serveContentFallback(log, w, r, path, fallback, true)
762
763 case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"):
764 // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri
765 // if the referenced Content-ID attachment can be found.
766 _, _, _, _, p, cleanup, ok := xprepare()
767 if !ok {
768 return
769 }
770 defer cleanup()
771
772 setHeaders := func() {
773 // Needed for inner document height for outer iframe height in separate message
774 // view. We only need that when displaying as a separate message on the msghtml*
775 // endpoints. When displaying in the regular webmail, we don't need to know the
776 // inner height so we load it as different origin, which should be safer.
777 sameorigin := r.URL.Query().Get("sameorigin") == "true"
778 allowExternal := strings.HasSuffix(t[1], "external")
779 headers(sameorigin, allowExternal, false, false)
780
781 h.Set("Content-Type", "text/html; charset=utf-8")
782 h.Set("Cache-Control", "no-store, max-age=0")
783 }
784
785 // todo: skip certain html parts? e.g. with content-disposition: attachment?
786 var done bool
787 var usePart func(p *message.Part, parents []*message.Part)
788 usePart = func(p *message.Part, parents []*message.Part) {
789 if done {
790 return
791 }
792 mt := p.MediaType + "/" + p.MediaSubType
793 switch mt {
794 case "TEXT/HTML":
795 done = true
796 err := inlineSanitizeHTML(log, setHeaders, w, p, parents)
797 if err != nil {
798 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
799 }
800 return
801 }
802 parents = append(parents, p)
803 for _, sp := range p.Parts {
804 usePart(&sp, parents)
805 }
806 }
807 usePart(&p, nil)
808
809 if !done {
810 http.Error(w, "400 - bad request - no html part in message", http.StatusBadRequest)
811 }
812
813 case len(t) == 3 && (t[1] == "view" || t[1] == "viewtext" || t[1] == "download"):
814 // View any part, as referenced in the last element path. "0" is the whole message,
815 // 0.0 is the first subpart, etc. "view" returns it with the content-type from the
816 // message (could be dangerous, but we set strict CSP headers), "viewtext" returns
817 // data with a text/plain content-type so the browser will attempt to display it,
818 // and "download" adds a content-disposition header causing the browser the
819 // download the file.
820 _, _, _, _, p, cleanup, ok := xprepare()
821 if !ok {
822 return
823 }
824 defer cleanup()
825
826 paths := strings.Split(t[2], ".")
827 if len(paths) == 0 || paths[0] != "0" {
828 http.NotFound(w, r)
829 return
830 }
831 ap := p
832 for _, e := range paths[1:] {
833 index, err := strconv.ParseInt(e, 10, 32)
834 if err != nil || index < 0 || int(index) >= len(ap.Parts) {
835 http.NotFound(w, r)
836 return
837 }
838 ap = ap.Parts[int(index)]
839 }
840
841 headers(false, false, false, false)
842 var ct string
843 if t[1] == "viewtext" {
844 ct = "text/plain"
845 } else {
846 ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
847 }
848 h.Set("Content-Type", ct)
849 h.Set("Cache-Control", "no-store, max-age=0")
850 if t[1] == "download" {
851 _, name, err := ap.DispositionFilename()
852 if err != nil && errors.Is(err, message.ErrParamEncoding) {
853 log.Debugx("parsing disposition/filename", err)
854 } else {
855 xcheckf(ctx, err, "reading disposition/filename")
856 }
857 if name == "" {
858 name = "attachment.bin"
859 }
860 cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
861 h.Set("Content-Disposition", cd)
862 }
863
864 _, err := io.Copy(w, ap.Reader())
865 log.Check(err, "copying attachment")
866 default:
867 http.NotFound(w, r)
868 }
869}
870
871// inlineSanitizeHTML writes the part as HTML, with "cid:" URIs for html "src"
872// attributes inlined and with potentially dangerous tags removed (javascript). The
873// sanitizing is just a first layer of defense, CSP headers block execution of
874// scripts. If the HTML becomes too large, an error is returned. Before writing
875// HTML, setHeaders is called to write the required headers for content-type and
876// CSP. On error, setHeader is not called, no output is written and the caller
877// should write an error response.
878func inlineSanitizeHTML(log mlog.Log, setHeaders func(), w io.Writer, p *message.Part, parents []*message.Part) error {
879 node, err := html.Parse(p.ReaderUTF8OrBinary())
880 if err != nil {
881 return fmt.Errorf("parsing html: %v", err)
882 }
883
884 // We track size, if it becomes too much, we abort and still copy as regular html.
885 var totalSize int64
886 if err := inlineNode(p, parents, node, &totalSize); err != nil {
887 return fmt.Errorf("inline cid uris in html nodes: %w", err)
888 }
889 sanitizeNode(node)
890 setHeaders()
891 err = html.Render(w, node)
892 log.Check(err, "writing html")
893 return nil
894}
895
896// findCID returns the part with the Content-ID matching cid, which includes
897// "<>", starting at the part's siblings, up the tree, and later from the
898// top-part down the tree.
899func findCID(p *message.Part, parents []*message.Part, cid string) *message.Part {
900 for _, parent := range slices.Backward(parents) {
901 for j, pp := range parent.Parts {
902 if pp.ContentID != nil && strings.EqualFold(*pp.ContentID, cid) {
903 return &parent.Parts[j]
904 }
905 }
906 }
907
908 if len(parents) > 0 {
909 return findCIDAll(parents[0], cid)
910 }
911 return nil
912}
913
914func findCIDAll(p *message.Part, cid string) *message.Part {
915 if p.ContentID != nil && strings.EqualFold(*p.ContentID, cid) {
916 return p
917 }
918 for i := range p.Parts {
919 pp := findCIDAll(&p.Parts[i], cid)
920 if pp != nil {
921 return pp
922 }
923 }
924 return nil
925}
926
927// We inline cid: URIs into data: URIs. If a cid is missing in the
928// multipart/related, we ignore the error and continue with other HTML nodes. It
929// will probably just result in a "broken image". We limit the max size we
930// generate. We only replace "src" attributes that start with "cid:". A cid URI
931// could theoretically occur in many more places, like link href, and css url().
932// That's probably not common though. Let's wait for someone to need it.
933func inlineNode(p *message.Part, parents []*message.Part, node *html.Node, totalSize *int64) error {
934 for i, a := range node.Attr {
935 if a.Key != "src" || !caselessPrefix(a.Val, "cid:") || a.Namespace != "" {
936 continue
937 }
938 cid := "<" + a.Val[4:] + ">"
939 ap := findCID(p, parents, cid)
940 if ap == nil {
941 // Missing cid, can happen with email, no need to stop returning data.
942 continue
943 }
944 *totalSize += ap.DecodedSize
945 if *totalSize >= 10*1024*1024 {
946 return fmt.Errorf("html too large")
947 }
948 var sb strings.Builder
949 if _, err := fmt.Fprintf(&sb, "data:%s;base64,", strings.ToLower(ap.MediaType+"/"+ap.MediaSubType)); err != nil {
950 return fmt.Errorf("writing datauri: %v", err)
951 }
952 w := base64.NewEncoder(base64.StdEncoding, &sb)
953 if _, err := io.Copy(w, ap.Reader()); err != nil {
954 return fmt.Errorf("writing base64 datauri: %v", err)
955 }
956 node.Attr[i].Val = sb.String()
957 }
958 for node = node.FirstChild; node != nil; node = node.NextSibling {
959 if err := inlineNode(p, parents, node, totalSize); err != nil {
960 return err
961 }
962 }
963 return nil
964}
965
966func caselessPrefix(k, pre string) bool {
967 return len(k) >= len(pre) && strings.EqualFold(k[:len(pre)], pre)
968}
969
970var targetable = map[string]bool{
971 "a": true,
972 "area": true,
973 "form": true,
974 "base": true,
975}
976
977// sanitizeNode removes script elements, on* attributes, javascript: href
978// attributes, adds target="_blank" to all links and to a base tag.
979func sanitizeNode(node *html.Node) {
980 i := 0
981 var haveTarget, haveRel bool
982 for i < len(node.Attr) {
983 a := node.Attr[i]
984 // Remove dangerous attributes.
985 if strings.HasPrefix(a.Key, "on") || a.Key == "href" && caselessPrefix(a.Val, "javascript:") || a.Key == "src" && caselessPrefix(a.Val, "data:text/html") {
986 copy(node.Attr[i:], node.Attr[i+1:])
987 node.Attr = node.Attr[:len(node.Attr)-1]
988 continue
989 }
990 if a.Key == "target" {
991 node.Attr[i].Val = "_blank"
992 haveTarget = true
993 }
994 if a.Key == "rel" && targetable[node.Data] {
995 node.Attr[i].Val = "noopener noreferrer"
996 haveRel = true
997 }
998 i++
999 }
1000 // Ensure target attribute is set for elements that can have it.
1001 if !haveTarget && node.Type == html.ElementNode && targetable[node.Data] {
1002 node.Attr = append(node.Attr, html.Attribute{Key: "target", Val: "_blank"})
1003 haveTarget = true
1004 }
1005 if haveTarget && !haveRel {
1006 node.Attr = append(node.Attr, html.Attribute{Key: "rel", Val: "noopener noreferrer"})
1007 }
1008
1009 parent := node
1010 node = node.FirstChild
1011 var haveBase bool
1012 for node != nil {
1013 // Set next now, we may remove cur, which clears its NextSibling.
1014 cur := node
1015 node = node.NextSibling
1016
1017 // Remove script elements.
1018 if cur.Type == html.ElementNode && cur.Data == "script" {
1019 parent.RemoveChild(cur)
1020 continue
1021 }
1022 sanitizeNode(cur)
1023 }
1024 if parent.Type == html.ElementNode && parent.Data == "head" && !haveBase {
1025 n := html.Node{Type: html.ElementNode, Data: "base", Attr: []html.Attribute{{Key: "target", Val: "_blank"}, {Key: "rel", Val: "noopener noreferrer"}}}
1026 parent.AppendChild(&n)
1027 }
1028}
1029