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