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 "bytes"
7 "context"
8 cryptorand "crypto/rand"
9 "encoding/base64"
10 "encoding/json"
11 "encoding/pem"
12 "errors"
13 "fmt"
14 "io"
15 "log/slog"
16 "net/http"
17 "net/url"
18 "os"
19 "path/filepath"
20 "runtime"
21 "strings"
22 "time"
23
24 _ "embed"
25
26 "github.com/mjl-/bstore"
27 "github.com/mjl-/sherpa"
28 "github.com/mjl-/sherpadoc"
29 "github.com/mjl-/sherpaprom"
30
31 "github.com/mjl-/mox/admin"
32 "github.com/mjl-/mox/config"
33 "github.com/mjl-/mox/mlog"
34 "github.com/mjl-/mox/mox-"
35 "github.com/mjl-/mox/moxvar"
36 "github.com/mjl-/mox/queue"
37 "github.com/mjl-/mox/smtp"
38 "github.com/mjl-/mox/store"
39 "github.com/mjl-/mox/webapi"
40 "github.com/mjl-/mox/webauth"
41 "github.com/mjl-/mox/webhook"
42 "github.com/mjl-/mox/webops"
43)
44
45var pkglog = mlog.New("webaccount", nil)
46
47//go:embed api.json
48var accountapiJSON []byte
49
50//go:embed account.html
51var accountHTML []byte
52
53//go:embed account.js
54var accountJS []byte
55
56var webaccountFile = &mox.WebappFile{
57 HTML: accountHTML,
58 JS: accountJS,
59 HTMLPath: filepath.FromSlash("webaccount/account.html"),
60 JSPath: filepath.FromSlash("webaccount/account.js"),
61 CustomStem: "webaccount",
62}
63
64var accountDoc = mustParseAPI("account", accountapiJSON)
65
66func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
67 err := json.Unmarshal(buf, &doc)
68 if err != nil {
69 pkglog.Fatalx("parsing webaccount api docs", err, slog.String("api", api))
70 }
71 return doc
72}
73
74var sherpaHandlerOpts *sherpa.HandlerOpts
75
76func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
77 return sherpa.NewHandler("/api/", moxvar.Version, Account{cookiePath, isForwarded}, &accountDoc, sherpaHandlerOpts)
78}
79
80func init() {
81 collector, err := sherpaprom.NewCollector("moxaccount", nil)
82 if err != nil {
83 pkglog.Fatalx("creating sherpa prometheus collector", err)
84 }
85
86 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
87 // Just to validate.
88 _, err = makeSherpaHandler("", false)
89 if err != nil {
90 pkglog.Fatalx("sherpa handler", err)
91 }
92
93 mox.NewWebaccountHandler = func(basePath string, isForwarded bool) http.Handler {
94 return http.HandlerFunc(Handler(basePath, isForwarded))
95 }
96}
97
98// Handler returns a handler for the webaccount endpoints, customized for the
99// cookiePath.
100func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
101 sh, err := makeSherpaHandler(cookiePath, isForwarded)
102 return func(w http.ResponseWriter, r *http.Request) {
103 if err != nil {
104 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
105 return
106 }
107 handle(sh, isForwarded, w, r)
108 }
109}
110
111func xcheckf(ctx context.Context, err error, format string, args ...any) {
112 if err == nil {
113 return
114 }
115 // If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
116 if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
117 xcheckuserf(ctx, err, format, args...)
118 }
119
120 msg := fmt.Sprintf(format, args...)
121 errmsg := fmt.Sprintf("%s: %s", msg, err)
122 pkglog.WithContext(ctx).Errorx(msg, err)
123 code := "server:error"
124 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
125 code = "user:error"
126 }
127 panic(&sherpa.Error{Code: code, Message: errmsg})
128}
129
130func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
131 if err == nil {
132 return
133 }
134 msg := fmt.Sprintf(format, args...)
135 errmsg := fmt.Sprintf("%s: %s", msg, err)
136 pkglog.WithContext(ctx).Errorx(msg, err)
137 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
138}
139
140// Account exports web API functions for the account web interface. All its
141// methods are exported under api/. Function calls require valid HTTP
142// Authentication credentials of a user.
143type Account struct {
144 cookiePath string // From listener, for setting authentication cookies.
145 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
146}
147
148func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
149 ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
150 log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
151
152 // Without authentication. The token is unguessable.
153 if r.URL.Path == "/importprogress" {
154 if r.Method != "GET" {
155 http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
156 return
157 }
158
159 q := r.URL.Query()
160 token := q.Get("token")
161 if token == "" {
162 http.Error(w, "400 - bad request - missing token", http.StatusBadRequest)
163 return
164 }
165
166 flusher, ok := w.(http.Flusher)
167 if !ok {
168 log.Error("internal error: ResponseWriter not a http.Flusher")
169 http.Error(w, "500 - internal error - cannot access underlying connection", 500)
170 return
171 }
172
173 l := importListener{token, make(chan importEvent, 100), make(chan bool, 1)}
174 importers.Register <- &l
175 ok = <-l.Register
176 if !ok {
177 http.Error(w, "400 - bad request - unknown token, import may have finished more than a minute ago", http.StatusBadRequest)
178 return
179 }
180 defer func() {
181 importers.Unregister <- &l
182 }()
183
184 h := w.Header()
185 h.Set("Content-Type", "text/event-stream")
186 h.Set("Cache-Control", "no-cache")
187 _, err := w.Write([]byte(": keepalive\n\n"))
188 if err != nil {
189 return
190 }
191 flusher.Flush()
192
193 cctx := r.Context()
194 for {
195 select {
196 case e := <-l.Events:
197 _, err := w.Write(e.SSEMsg)
198 flusher.Flush()
199 if err != nil {
200 return
201 }
202
203 case <-cctx.Done():
204 return
205 }
206 }
207 }
208
209 // HTML/JS can be retrieved without authentication.
210 if r.URL.Path == "/" {
211 switch r.Method {
212 case "GET", "HEAD":
213 webaccountFile.Serve(ctx, log, w, r)
214 default:
215 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
216 }
217 return
218 } else if r.URL.Path == "/licenses.txt" {
219 switch r.Method {
220 case "GET", "HEAD":
221 mox.LicensesWrite(w)
222 default:
223 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
224 }
225 return
226 }
227
228 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
229 // Only allow POST for calls, they will not work cross-domain without CORS.
230 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
231 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
232 return
233 }
234
235 var loginAddress, accName string
236 var sessionToken store.SessionToken
237 // All other URLs, except the login endpoint require some authentication.
238 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
239 var ok bool
240 isExport := r.URL.Path == "/export"
241 requireCSRF := isAPI || r.URL.Path == "/import" || isExport
242 accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webaccount", isForwarded, w, r, isAPI, requireCSRF, isExport)
243 if !ok {
244 // Response has been written already.
245 return
246 }
247 }
248
249 if isAPI {
250 reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
251 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
252 apiHandler.ServeHTTP(w, r.WithContext(ctx))
253 return
254 }
255
256 switch r.URL.Path {
257 case "/export":
258 webops.Export(log, accName, w, r)
259
260 case "/import":
261 if r.Method != "POST" {
262 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
263 return
264 }
265
266 f, _, err := r.FormFile("file")
267 if err != nil {
268 if errors.Is(err, http.ErrMissingFile) {
269 http.Error(w, "400 - bad request - missing file", http.StatusBadRequest)
270 } else {
271 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
272 }
273 return
274 }
275 defer func() {
276 err := f.Close()
277 log.Check(err, "closing form file")
278 }()
279 skipMailboxPrefix := r.FormValue("skipMailboxPrefix")
280 tmpf, err := os.CreateTemp("", "mox-import")
281 if err != nil {
282 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
283 return
284 }
285 defer func() {
286 if tmpf != nil {
287 store.CloseRemoveTempFile(log, tmpf, "upload")
288 }
289 }()
290 if _, err := io.Copy(tmpf, f); err != nil {
291 log.Errorx("copying import to temporary file", err)
292 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
293 return
294 }
295 token, isUserError, err := importStart(log, accName, tmpf, skipMailboxPrefix)
296 if err != nil {
297 log.Errorx("starting import", err, slog.Bool("usererror", isUserError))
298 if isUserError {
299 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
300 } else {
301 http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
302 }
303 return
304 }
305 tmpf = nil // importStart is now responsible for cleanup.
306
307 w.Header().Set("Content-Type", "application/json")
308 _ = json.NewEncoder(w).Encode(ImportProgress{Token: token})
309
310 default:
311 http.NotFound(w, r)
312 }
313}
314
315// ImportProgress is returned after uploading a file to import.
316type ImportProgress struct {
317 // For fetching progress, or cancelling an import.
318 Token string
319}
320
321type ctxKey string
322
323var requestInfoCtxKey ctxKey = "requestInfo"
324
325type requestInfo struct {
326 LoginAddress string
327 AccountName string
328 SessionToken store.SessionToken
329 Response http.ResponseWriter
330 Request *http.Request // For Proto and TLS connection state during message submit.
331}
332
333// LoginPrep returns a login token, and also sets it as cookie. Both must be
334// present in the call to Login.
335func (w Account) LoginPrep(ctx context.Context) string {
336 log := pkglog.WithContext(ctx)
337 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
338
339 var data [8]byte
340 _, err := cryptorand.Read(data[:])
341 xcheckf(ctx, err, "generate token")
342 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
343
344 webauth.LoginPrep(ctx, log, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
345
346 return loginToken
347}
348
349// Login returns a session token for the credentials, or fails with error code
350// "user:badLogin". Call LoginPrep to get a loginToken.
351func (w Account) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
352 log := pkglog.WithContext(ctx)
353 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
354
355 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
356 if _, ok := err.(*sherpa.Error); ok {
357 panic(err)
358 }
359 xcheckf(ctx, err, "login")
360 return csrfToken
361}
362
363// Logout invalidates the session token.
364func (w Account) Logout(ctx context.Context) {
365 log := pkglog.WithContext(ctx)
366 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
367
368 err := webauth.Logout(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
369 xcheckf(ctx, err, "logout")
370}
371
372// Version returns the version, goos and goarch.
373func (Account) Version(ctx context.Context) (version, goos, goarch string) {
374 return moxvar.Version, runtime.GOOS, runtime.GOARCH
375}
376
377// SetPassword saves a new password for the account, invalidating the previous
378// password.
379//
380// Sessions are not interrupted, and will keep working. New login attempts must use
381// the new password.
382//
383// Password must be at least 8 characters.
384//
385// Setting a user-supplied password is not allowed if NoCustomPassword is set
386// for the account.
387func (Account) SetPassword(ctx context.Context, password string) {
388 log := pkglog.WithContext(ctx)
389 if len(password) < 8 {
390 panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
391 }
392
393 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
394 acc, err := store.OpenAccount(log, reqInfo.AccountName, false)
395 xcheckf(ctx, err, "open account")
396 defer func() {
397 err := acc.Close()
398 log.Check(err, "closing account")
399 }()
400
401 accConf, _ := acc.Conf()
402 if accConf.NoCustomPassword {
403 xcheckuserf(ctx, errors.New("custom password not allowed"), "setting password")
404 }
405
406 // Retrieve session, resetting password invalidates it.
407 ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
408 xcheckf(ctx, err, "get session")
409
410 err = acc.SetPassword(log, password)
411 xcheckf(ctx, err, "setting password")
412
413 // Session has been invalidated. Add it again.
414 err = store.SessionAddToken(ctx, log, &ls)
415 xcheckf(ctx, err, "restoring session after password reset")
416}
417
418// GeneratePassword sets a new randomly generated password for the current account.
419// Sessions are not interrupted, and will keep working.
420func (Account) GeneratePassword(ctx context.Context) (password string) {
421 log := pkglog.WithContext(ctx)
422
423 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
424 acc, err := store.OpenAccount(log, reqInfo.AccountName, false)
425 xcheckf(ctx, err, "open account")
426 defer func() {
427 err := acc.Close()
428 log.Check(err, "closing account")
429 }()
430
431 password = mox.GeneratePassword()
432
433 // Retrieve session, resetting password invalidates it.
434 ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
435 xcheckf(ctx, err, "get session")
436
437 err = acc.SetPassword(log, password)
438 xcheckf(ctx, err, "setting password")
439
440 // Session has been invalidated. Add it again.
441 err = store.SessionAddToken(ctx, log, &ls)
442 xcheckf(ctx, err, "restoring session after password reset")
443
444 return
445}
446
447// Account returns information about the account.
448// StorageUsed is the sum of the sizes of all messages, in bytes.
449// StorageLimit is the maximum storage that can be used, or 0 if there is no limit.
450func (Account) Account(ctx context.Context) (account config.Account, storageUsed, storageLimit int64, suppressions []webapi.Suppression) {
451 log := pkglog.WithContext(ctx)
452 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
453
454 acc, err := store.OpenAccount(log, reqInfo.AccountName, false)
455 xcheckf(ctx, err, "open account")
456 defer func() {
457 err := acc.Close()
458 log.Check(err, "closing account")
459 }()
460
461 var accConf config.Account
462 acc.WithRLock(func() {
463 accConf, _ = acc.Conf()
464
465 storageLimit = acc.QuotaMessageSize()
466 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
467 du := store.DiskUsage{ID: 1}
468 err := tx.Get(&du)
469 storageUsed = du.MessageSize
470 return err
471 })
472 xcheckf(ctx, err, "get disk usage")
473 })
474
475 suppressions, err = queue.SuppressionList(ctx, reqInfo.AccountName)
476 xcheckf(ctx, err, "list suppressions")
477
478 return accConf, storageUsed, storageLimit, suppressions
479}
480
481// AccountSaveFullName saves the full name (used as display name in email messages)
482// for the account.
483func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
484 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
485 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
486 acc.FullName = fullName
487 })
488 xcheckf(ctx, err, "saving account full name")
489}
490
491// DestinationSave updates a destination.
492// OldDest is compared against the current destination. If it does not match, an
493// error is returned. Otherwise newDest is saved and the configuration reloaded.
494func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
495 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
496
497 err := admin.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
498 curDest, ok := conf.Destinations[destName]
499 if !ok {
500 xcheckuserf(ctx, errors.New("not found"), "looking up destination")
501 }
502 if !curDest.Equal(oldDest) {
503 xcheckuserf(ctx, errors.New("modified"), "checking stored destination")
504 }
505
506 // Keep fields we manage.
507 newDest.DMARCReports = curDest.DMARCReports
508 newDest.HostTLSReports = curDest.HostTLSReports
509 newDest.DomainTLSReports = curDest.DomainTLSReports
510
511 // Make copy of reference values.
512 nd := map[string]config.Destination{}
513 for dn, d := range conf.Destinations {
514 nd[dn] = d
515 }
516 nd[destName] = newDest
517 conf.Destinations = nd
518 })
519 xcheckf(ctx, err, "saving destination")
520}
521
522// ImportAbort aborts an import that is in progress. If the import exists and isn't
523// finished, no changes will have been made by the import.
524func (Account) ImportAbort(ctx context.Context, importToken string) error {
525 req := importAbortRequest{importToken, make(chan error)}
526 importers.Abort <- req
527 return <-req.Response
528}
529
530// Types exposes types not used in API method signatures, such as the import form upload.
531func (Account) Types() (importProgress ImportProgress) {
532 return
533}
534
535// SuppressionList lists the addresses on the suppression list of this account.
536func (Account) SuppressionList(ctx context.Context) (suppressions []webapi.Suppression) {
537 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
538 l, err := queue.SuppressionList(ctx, reqInfo.AccountName)
539 xcheckf(ctx, err, "list suppressions")
540 return l
541}
542
543// SuppressionAdd adds an email address to the suppression list.
544func (Account) SuppressionAdd(ctx context.Context, address string, manual bool, reason string) (suppression webapi.Suppression) {
545 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
546 addr, err := smtp.ParseAddress(address)
547 xcheckuserf(ctx, err, "parsing address")
548 sup := webapi.Suppression{
549 Account: reqInfo.AccountName,
550 Manual: manual,
551 Reason: reason,
552 }
553 err = queue.SuppressionAdd(ctx, addr.Path(), &sup)
554 if err != nil && errors.Is(err, bstore.ErrUnique) {
555 xcheckuserf(ctx, err, "add suppression")
556 }
557 xcheckf(ctx, err, "add suppression")
558 return sup
559}
560
561// SuppressionRemove removes the email address from the suppression list.
562func (Account) SuppressionRemove(ctx context.Context, address string) {
563 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
564 addr, err := smtp.ParseAddress(address)
565 xcheckuserf(ctx, err, "parsing address")
566 err = queue.SuppressionRemove(ctx, reqInfo.AccountName, addr.Path())
567 if err != nil && err == bstore.ErrAbsent {
568 xcheckuserf(ctx, err, "remove suppression")
569 }
570 xcheckf(ctx, err, "remove suppression")
571}
572
573// OutgoingWebhookSave saves a new webhook url for outgoing deliveries. If url
574// is empty, the webhook is disabled. If authorization is non-empty it is used for
575// the Authorization header in HTTP requests. Events specifies the outgoing events
576// to be delivered, or all if empty/nil.
577func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) {
578 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
579 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
580 if url == "" {
581 acc.OutgoingWebhook = nil
582 } else {
583 acc.OutgoingWebhook = &config.OutgoingWebhook{URL: url, Authorization: authorization, Events: events}
584 }
585 })
586 xcheckf(ctx, err, "saving account outgoing webhook")
587}
588
589// OutgoingWebhookTest makes a test webhook call to urlStr, with optional
590// authorization. If the HTTP request is made this call will succeed also for
591// non-2xx HTTP status codes.
592func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization string, data webhook.Outgoing) (code int, response string, errmsg string) {
593 log := pkglog.WithContext(ctx)
594
595 xvalidURL(ctx, urlStr)
596 log.Debug("making webhook test call for outgoing message", slog.String("url", urlStr))
597
598 var b bytes.Buffer
599 enc := json.NewEncoder(&b)
600 enc.SetIndent("", "\t")
601 enc.SetEscapeHTML(false)
602 err := enc.Encode(data)
603 xcheckf(ctx, err, "encoding outgoing webhook data")
604
605 code, response, err = queue.HookPost(ctx, log, 1, 1, urlStr, authorization, b.String())
606 if err != nil {
607 errmsg = err.Error()
608 }
609 log.Debugx("result for webhook test call for outgoing message", err, slog.Int("code", code), slog.String("response", response))
610 return code, response, errmsg
611}
612
613// IncomingWebhookSave saves a new webhook url for incoming deliveries. If url is
614// empty, the webhook is disabled. If authorization is not empty, it is used in
615// the Authorization header in requests.
616func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) {
617 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
618 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
619 if url == "" {
620 acc.IncomingWebhook = nil
621 } else {
622 acc.IncomingWebhook = &config.IncomingWebhook{URL: url, Authorization: authorization}
623 }
624 })
625 xcheckf(ctx, err, "saving account incoming webhook")
626}
627
628func xvalidURL(ctx context.Context, s string) {
629 u, err := url.Parse(s)
630 xcheckuserf(ctx, err, "parsing url")
631 if u.Scheme != "http" && u.Scheme != "https" {
632 xcheckuserf(ctx, errors.New("scheme must be http or https"), "parsing url")
633 }
634}
635
636// IncomingWebhookTest makes a test webhook HTTP delivery request to urlStr,
637// with optional authorization header. If the HTTP call is made, this function
638// returns non-error regardless of HTTP status code.
639func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization string, data webhook.Incoming) (code int, response string, errmsg string) {
640 log := pkglog.WithContext(ctx)
641
642 xvalidURL(ctx, urlStr)
643 log.Debug("making webhook test call for incoming message", slog.String("url", urlStr))
644
645 var b bytes.Buffer
646 enc := json.NewEncoder(&b)
647 enc.SetEscapeHTML(false)
648 enc.SetIndent("", "\t")
649 err := enc.Encode(data)
650 xcheckf(ctx, err, "encoding incoming webhook data")
651 code, response, err = queue.HookPost(ctx, log, 1, 1, urlStr, authorization, b.String())
652 if err != nil {
653 errmsg = err.Error()
654 }
655 log.Debugx("result for webhook test call for incoming message", err, slog.Int("code", code), slog.String("response", response))
656 return code, response, errmsg
657}
658
659// FromIDLoginAddressesSave saves new login addresses to enable unique SMTP
660// MAIL FROM addresses ("fromid") for deliveries from the queue.
661func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) {
662 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
663 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
664 acc.FromIDLoginAddresses = loginAddresses
665 })
666 xcheckf(ctx, err, "saving account fromid login addresses")
667}
668
669// KeepRetiredPeriodsSave saves periods to save retired messages and webhooks.
670func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) {
671 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
672 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
673 acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod
674 acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod
675 })
676 xcheckf(ctx, err, "saving account keep retired periods")
677}
678
679// AutomaticJunkFlagsSave saves settings for automatically marking messages as
680// junk/nonjunk when moved to mailboxes matching certain regular expressions.
681func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) {
682 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
683 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
684 acc.AutomaticJunkFlags = config.AutomaticJunkFlags{
685 Enabled: enabled,
686 JunkMailboxRegexp: junkRegexp,
687 NeutralMailboxRegexp: neutralRegexp,
688 NotJunkMailboxRegexp: notJunkRegexp,
689 }
690 })
691 xcheckf(ctx, err, "saving account automatic junk flags")
692}
693
694// JunkFilterSave saves junk filter settings. If junkFilter is nil, the junk filter
695// is disabled. Otherwise all fields except Threegrams are stored.
696func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) {
697 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
698 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
699 if junkFilter == nil {
700 acc.JunkFilter = nil
701 return
702 }
703 old := acc.JunkFilter
704 acc.JunkFilter = junkFilter
705 acc.JunkFilter.Params.Threegrams = false
706 if old != nil {
707 acc.JunkFilter.Params.Threegrams = old.Params.Threegrams
708 }
709 })
710 xcheckf(ctx, err, "saving account junk filter settings")
711}
712
713// RejectsSave saves the RejectsMailbox and KeepRejects settings.
714func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
715 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
716 err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
717 acc.RejectsMailbox = mailbox
718 acc.KeepRejects = keep
719 })
720 xcheckf(ctx, err, "saving account rejects settings")
721}
722
723func (Account) TLSPublicKeys(ctx context.Context) ([]store.TLSPublicKey, error) {
724 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
725 return store.TLSPublicKeyList(ctx, reqInfo.AccountName)
726}
727
728func (Account) TLSPublicKeyAdd(ctx context.Context, loginAddress, name string, noIMAPPreauth bool, certPEM string) (store.TLSPublicKey, error) {
729 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
730
731 block, rest := pem.Decode([]byte(certPEM))
732 var err error
733 if block == nil {
734 err = errors.New("no pem data found")
735 } else if block.Type != "CERTIFICATE" {
736 err = fmt.Errorf("unexpected type %q, need CERTIFICATE", block.Type)
737 } else if len(rest) != 0 {
738 err = errors.New("only single pem block allowed")
739 }
740 xcheckuserf(ctx, err, "parsing pem file")
741
742 tpk, err := store.ParseTLSPublicKeyCert(block.Bytes)
743 xcheckuserf(ctx, err, "parsing certificate")
744 if name != "" {
745 tpk.Name = name
746 }
747 tpk.Account = reqInfo.AccountName
748 tpk.LoginAddress = loginAddress
749 tpk.NoIMAPPreauth = noIMAPPreauth
750 err = store.TLSPublicKeyAdd(ctx, &tpk)
751 if err != nil && errors.Is(err, bstore.ErrUnique) {
752 xcheckuserf(ctx, err, "add tls public key")
753 } else {
754 xcheckf(ctx, err, "add tls public key")
755 }
756 return tpk, nil
757}
758
759func xtlspublickey(ctx context.Context, account string, fingerprint string) store.TLSPublicKey {
760 tpk, err := store.TLSPublicKeyGet(ctx, fingerprint)
761 if err == nil && tpk.Account != account {
762 err = bstore.ErrAbsent
763 }
764 if err == bstore.ErrAbsent {
765 xcheckuserf(ctx, err, "get tls public key")
766 }
767 xcheckf(ctx, err, "get tls public key")
768 return tpk
769}
770
771func (Account) TLSPublicKeyRemove(ctx context.Context, fingerprint string) error {
772 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
773 xtlspublickey(ctx, reqInfo.AccountName, fingerprint)
774 return store.TLSPublicKeyRemove(ctx, fingerprint)
775}
776
777func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey) error {
778 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
779 tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint)
780 log := pkglog.WithContext(ctx)
781 acc, _, _, err := store.OpenEmail(log, pubKey.LoginAddress, false)
782 if err == nil && acc.Name != reqInfo.AccountName {
783 err = store.ErrUnknownCredentials
784 }
785 if acc != nil {
786 xerr := acc.Close()
787 log.Check(xerr, "close account")
788 }
789 if err == store.ErrUnknownCredentials {
790 xcheckuserf(ctx, errors.New("unknown address"), "looking up address")
791 }
792 tpk.Name = pubKey.Name
793 tpk.LoginAddress = pubKey.LoginAddress
794 tpk.NoIMAPPreauth = pubKey.NoIMAPPreauth
795 err = store.TLSPublicKeyUpdate(ctx, &tpk)
796 xcheckf(ctx, err, "updating tls public key")
797 return nil
798}
799
800func (Account) LoginAttempts(ctx context.Context, limit int) []store.LoginAttempt {
801 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
802 l, err := store.LoginAttemptList(ctx, reqInfo.AccountName, limit)
803 xcheckf(ctx, err, "listing login attempts")
804 return l
805}
806