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