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