1// Package webaccount provides a web app for users to view and change their account
2// settings, and to import/export email.
3package webaccount
4
5import (
6 "archive/tar"
7 "archive/zip"
8 "compress/gzip"
9 "context"
10 cryptorand "crypto/rand"
11 "encoding/base64"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "io"
16 "log/slog"
17 "net/http"
18 "os"
19 "path/filepath"
20 "strings"
21
22 _ "embed"
23
24 "github.com/mjl-/sherpa"
25 "github.com/mjl-/sherpadoc"
26 "github.com/mjl-/sherpaprom"
27
28 "github.com/mjl-/mox/config"
29 "github.com/mjl-/mox/dns"
30 "github.com/mjl-/mox/mlog"
31 "github.com/mjl-/mox/mox-"
32 "github.com/mjl-/mox/moxvar"
33 "github.com/mjl-/mox/store"
34 "github.com/mjl-/mox/webauth"
35)
36
37var pkglog = mlog.New("webaccount", nil)
38
39//go:embed api.json
40var accountapiJSON []byte
41
42//go:embed account.html
43var accountHTML []byte
44
45//go:embed account.js
46var accountJS []byte
47
48var webaccountFile = &mox.WebappFile{
49 HTML: accountHTML,
50 JS: accountJS,
51 HTMLPath: filepath.FromSlash("webaccount/account.html"),
52 JSPath: filepath.FromSlash("webaccount/account.js"),
53}
54
55var accountDoc = mustParseAPI("account", accountapiJSON)
56
57func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
58 err := json.Unmarshal(buf, &doc)
59 if err != nil {
60 pkglog.Fatalx("parsing webaccount api docs", err, slog.String("api", api))
61 }
62 return doc
63}
64
65var sherpaHandlerOpts *sherpa.HandlerOpts
66
67func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
68 return sherpa.NewHandler("/api/", moxvar.Version, Account{cookiePath, isForwarded}, &accountDoc, sherpaHandlerOpts)
69}
70
71func init() {
72 collector, err := sherpaprom.NewCollector("moxaccount", nil)
73 if err != nil {
74 pkglog.Fatalx("creating sherpa prometheus collector", err)
75 }
76
77 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
78 // Just to validate.
79 _, err = makeSherpaHandler("", false)
80 if err != nil {
81 pkglog.Fatalx("sherpa handler", err)
82 }
83}
84
85// Handler returns a handler for the webaccount endpoints, customized for the
86// cookiePath.
87func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
88 sh, err := makeSherpaHandler(cookiePath, isForwarded)
89 return func(w http.ResponseWriter, r *http.Request) {
90 if err != nil {
91 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
92 return
93 }
94 handle(sh, isForwarded, w, r)
95 }
96}
97
98func xcheckf(ctx context.Context, err error, format string, args ...any) {
99 if err == nil {
100 return
101 }
102 msg := fmt.Sprintf(format, args...)
103 errmsg := fmt.Sprintf("%s: %s", msg, err)
104 pkglog.WithContext(ctx).Errorx(msg, err)
105 code := "server:error"
106 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
107 code = "user:error"
108 }
109 panic(&sherpa.Error{Code: code, Message: errmsg})
110}
111
112func xcheckuserf(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 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
120}
121
122// Account exports web API functions for the account web interface. All its
123// methods are exported under api/. Function calls require valid HTTP
124// Authentication credentials of a user.
125type Account struct {
126 cookiePath string // From listener, for setting authentication cookies.
127 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
128}
129
130func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
131 ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
132 log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
133
134 // Without authentication. The token is unguessable.
135 if r.URL.Path == "/importprogress" {
136 if r.Method != "GET" {
137 http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
138 return
139 }
140
141 q := r.URL.Query()
142 token := q.Get("token")
143 if token == "" {
144 http.Error(w, "400 - bad request - missing token", http.StatusBadRequest)
145 return
146 }
147
148 flusher, ok := w.(http.Flusher)
149 if !ok {
150 log.Error("internal error: ResponseWriter not a http.Flusher")
151 http.Error(w, "500 - internal error - cannot access underlying connection", 500)
152 return
153 }
154
155 l := importListener{token, make(chan importEvent, 100), make(chan bool, 1)}
156 importers.Register <- &l
157 ok = <-l.Register
158 if !ok {
159 http.Error(w, "400 - bad request - unknown token, import may have finished more than a minute ago", http.StatusBadRequest)
160 return
161 }
162 defer func() {
163 importers.Unregister <- &l
164 }()
165
166 h := w.Header()
167 h.Set("Content-Type", "text/event-stream")
168 h.Set("Cache-Control", "no-cache")
169 _, err := w.Write([]byte(": keepalive\n\n"))
170 if err != nil {
171 return
172 }
173 flusher.Flush()
174
175 cctx := r.Context()
176 for {
177 select {
178 case e := <-l.Events:
179 _, err := w.Write(e.SSEMsg)
180 flusher.Flush()
181 if err != nil {
182 return
183 }
184
185 case <-cctx.Done():
186 return
187 }
188 }
189 }
190
191 // HTML/JS can be retrieved without authentication.
192 if r.URL.Path == "/" {
193 switch r.Method {
194 case "GET", "HEAD":
195 webaccountFile.Serve(ctx, log, w, r)
196 default:
197 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
198 }
199 return
200 }
201
202 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
203 // Only allow POST for calls, they will not work cross-domain without CORS.
204 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
205 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
206 return
207 }
208
209 var loginAddress, accName string
210 var sessionToken store.SessionToken
211 // All other URLs, except the login endpoint require some authentication.
212 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
213 var ok bool
214 isExport := strings.HasPrefix(r.URL.Path, "/export/")
215 requireCSRF := isAPI || r.URL.Path == "/import" || isExport
216 accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webaccount", isForwarded, w, r, isAPI, requireCSRF, isExport)
217 if !ok {
218 // Response has been written already.
219 return
220 }
221 }
222
223 if isAPI {
224 reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
225 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
226 apiHandler.ServeHTTP(w, r.WithContext(ctx))
227 return
228 }
229
230 switch r.URL.Path {
231 case "/export/mail-export-maildir.tgz", "/export/mail-export-maildir.zip", "/export/mail-export-mbox.tgz", "/export/mail-export-mbox.zip":
232 if r.Method != "POST" {
233 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
234 return
235 }
236
237 maildir := strings.Contains(r.URL.Path, "maildir")
238 tgz := strings.Contains(r.URL.Path, ".tgz")
239
240 acc, err := store.OpenAccount(log, accName)
241 if err != nil {
242 log.Errorx("open account for export", err)
243 http.Error(w, "500 - internal server error", http.StatusInternalServerError)
244 return
245 }
246 defer func() {
247 err := acc.Close()
248 log.Check(err, "closing account")
249 }()
250
251 var archiver store.Archiver
252 if tgz {
253 // Don't tempt browsers to "helpfully" decompress.
254 w.Header().Set("Content-Type", "application/octet-stream")
255
256 gzw := gzip.NewWriter(w)
257 defer func() {
258 _ = gzw.Close()
259 }()
260 archiver = store.TarArchiver{Writer: tar.NewWriter(gzw)}
261 } else {
262 w.Header().Set("Content-Type", "application/zip")
263 archiver = store.ZipArchiver{Writer: zip.NewWriter(w)}
264 }
265 defer func() {
266 err := archiver.Close()
267 log.Check(err, "exporting mail close")
268 }()
269 if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, maildir, ""); err != nil {
270 log.Errorx("exporting mail", err)
271 }
272
273 case "/import":
274 if r.Method != "POST" {
275 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
276 return
277 }
278
279 f, _, err := r.FormFile("file")
280 if err != nil {
281 if errors.Is(err, http.ErrMissingFile) {
282 http.Error(w, "400 - bad request - missing file", http.StatusBadRequest)
283 } else {
284 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
285 }
286 return
287 }
288 defer func() {
289 err := f.Close()
290 log.Check(err, "closing form file")
291 }()
292 skipMailboxPrefix := r.FormValue("skipMailboxPrefix")
293 tmpf, err := os.CreateTemp("", "mox-import")
294 if err != nil {
295 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
296 return
297 }
298 defer func() {
299 if tmpf != nil {
300 store.CloseRemoveTempFile(log, tmpf, "upload")
301 }
302 }()
303 if _, err := io.Copy(tmpf, f); err != nil {
304 log.Errorx("copying import to temporary file", err)
305 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
306 return
307 }
308 token, isUserError, err := importStart(log, accName, tmpf, skipMailboxPrefix)
309 if err != nil {
310 log.Errorx("starting import", err, slog.Bool("usererror", isUserError))
311 if isUserError {
312 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
313 } else {
314 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
315 }
316 return
317 }
318 tmpf = nil // importStart is now responsible for cleanup.
319
320 w.Header().Set("Content-Type", "application/json")
321 _ = json.NewEncoder(w).Encode(ImportProgress{Token: token})
322
323 default:
324 http.NotFound(w, r)
325 }
326}
327
328// ImportProgress is returned after uploading a file to import.
329type ImportProgress struct {
330 // For fetching progress, or cancelling an import.
331 Token string
332}
333
334type ctxKey string
335
336var requestInfoCtxKey ctxKey = "requestInfo"
337
338type requestInfo struct {
339 LoginAddress string
340 AccountName string
341 SessionToken store.SessionToken
342 Response http.ResponseWriter
343 Request *http.Request // For Proto and TLS connection state during message submit.
344}
345
346// LoginPrep returns a login token, and also sets it as cookie. Both must be
347// present in the call to Login.
348func (w Account) LoginPrep(ctx context.Context) string {
349 log := pkglog.WithContext(ctx)
350 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
351
352 var data [8]byte
353 _, err := cryptorand.Read(data[:])
354 xcheckf(ctx, err, "generate token")
355 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
356
357 webauth.LoginPrep(ctx, log, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
358
359 return loginToken
360}
361
362// Login returns a session token for the credentials, or fails with error code
363// "user:badLogin". Call LoginPrep to get a loginToken.
364func (w Account) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
365 log := pkglog.WithContext(ctx)
366 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
367
368 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
369 if _, ok := err.(*sherpa.Error); ok {
370 panic(err)
371 }
372 xcheckf(ctx, err, "login")
373 return csrfToken
374}
375
376// Logout invalidates the session token.
377func (w Account) Logout(ctx context.Context) {
378 log := pkglog.WithContext(ctx)
379 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
380
381 err := webauth.Logout(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
382 xcheckf(ctx, err, "logout")
383}
384
385// SetPassword saves a new password for the account, invalidating the previous password.
386// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
387// Password must be at least 8 characters.
388func (Account) SetPassword(ctx context.Context, password string) {
389 log := pkglog.WithContext(ctx)
390 if len(password) < 8 {
391 panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
392 }
393
394 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
395 acc, err := store.OpenAccount(log, reqInfo.AccountName)
396 xcheckf(ctx, err, "open account")
397 defer func() {
398 err := acc.Close()
399 log.Check(err, "closing account")
400 }()
401
402 // Retrieve session, resetting password invalidates it.
403 ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
404 xcheckf(ctx, err, "get session")
405
406 err = acc.SetPassword(log, password)
407 xcheckf(ctx, err, "setting password")
408
409 // Session has been invalidated. Add it again.
410 err = store.SessionAddToken(ctx, log, &ls)
411 xcheckf(ctx, err, "restoring session after password reset")
412}
413
414// Account returns information about the account: full name, the default domain,
415// and the destinations (keys are email addresses, or localparts to the default
416// domain). todo: replace with a function that returns the whole account, when
417// sherpadoc understands unnamed struct fields.
418func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]config.Destination) {
419 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
420 accConf, ok := mox.Conf.Account(reqInfo.AccountName)
421 if !ok {
422 xcheckf(ctx, errors.New("not found"), "looking up account")
423 }
424 return accConf.FullName, accConf.DNSDomain, accConf.Destinations
425}
426
427func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
428 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
429 _, ok := mox.Conf.Account(reqInfo.AccountName)
430 if !ok {
431 xcheckf(ctx, errors.New("not found"), "looking up account")
432 }
433 err := mox.AccountFullNameSave(ctx, reqInfo.AccountName, fullName)
434 xcheckf(ctx, err, "saving account full name")
435}
436
437// DestinationSave updates a destination.
438// OldDest is compared against the current destination. If it does not match, an
439// error is returned. Otherwise newDest is saved and the configuration reloaded.
440func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
441 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
442 accConf, ok := mox.Conf.Account(reqInfo.AccountName)
443 if !ok {
444 xcheckf(ctx, errors.New("not found"), "looking up account")
445 }
446 curDest, ok := accConf.Destinations[destName]
447 if !ok {
448 xcheckuserf(ctx, errors.New("not found"), "looking up destination")
449 }
450
451 if !curDest.Equal(oldDest) {
452 xcheckuserf(ctx, errors.New("modified"), "checking stored destination")
453 }
454
455 // Keep fields we manage.
456 newDest.DMARCReports = curDest.DMARCReports
457 newDest.HostTLSReports = curDest.HostTLSReports
458 newDest.DomainTLSReports = curDest.DomainTLSReports
459
460 err := mox.DestinationSave(ctx, reqInfo.AccountName, destName, newDest)
461 xcheckf(ctx, err, "saving destination")
462}
463
464// ImportAbort aborts an import that is in progress. If the import exists and isn't
465// finished, no changes will have been made by the import.
466func (Account) ImportAbort(ctx context.Context, importToken string) error {
467 req := importAbortRequest{importToken, make(chan error)}
468 importers.Abort <- req
469 return <-req.Response
470}
471
472// Types exposes types not used in API method signatures, such as the import form upload.
473func (Account) Types() (importProgress ImportProgress) {
474 return
475}
476