mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
baf4df55a6
so users can change it themselves, instead of requiring an admin to change the settings.
694 lines
23 KiB
Go
694 lines
23 KiB
Go
// Package webaccount provides a web app for users to view and change their account
|
|
// settings, and to import/export email.
|
|
package webaccount
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "embed"
|
|
|
|
"github.com/mjl-/bstore"
|
|
"github.com/mjl-/sherpa"
|
|
"github.com/mjl-/sherpadoc"
|
|
"github.com/mjl-/sherpaprom"
|
|
|
|
"github.com/mjl-/mox/config"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/mox-"
|
|
"github.com/mjl-/mox/moxvar"
|
|
"github.com/mjl-/mox/queue"
|
|
"github.com/mjl-/mox/smtp"
|
|
"github.com/mjl-/mox/store"
|
|
"github.com/mjl-/mox/webapi"
|
|
"github.com/mjl-/mox/webauth"
|
|
"github.com/mjl-/mox/webhook"
|
|
)
|
|
|
|
var pkglog = mlog.New("webaccount", nil)
|
|
|
|
//go:embed api.json
|
|
var accountapiJSON []byte
|
|
|
|
//go:embed account.html
|
|
var accountHTML []byte
|
|
|
|
//go:embed account.js
|
|
var accountJS []byte
|
|
|
|
var webaccountFile = &mox.WebappFile{
|
|
HTML: accountHTML,
|
|
JS: accountJS,
|
|
HTMLPath: filepath.FromSlash("webaccount/account.html"),
|
|
JSPath: filepath.FromSlash("webaccount/account.js"),
|
|
}
|
|
|
|
var accountDoc = mustParseAPI("account", accountapiJSON)
|
|
|
|
func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
|
|
err := json.Unmarshal(buf, &doc)
|
|
if err != nil {
|
|
pkglog.Fatalx("parsing webaccount api docs", err, slog.String("api", api))
|
|
}
|
|
return doc
|
|
}
|
|
|
|
var sherpaHandlerOpts *sherpa.HandlerOpts
|
|
|
|
func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
|
|
return sherpa.NewHandler("/api/", moxvar.Version, Account{cookiePath, isForwarded}, &accountDoc, sherpaHandlerOpts)
|
|
}
|
|
|
|
func init() {
|
|
collector, err := sherpaprom.NewCollector("moxaccount", nil)
|
|
if err != nil {
|
|
pkglog.Fatalx("creating sherpa prometheus collector", err)
|
|
}
|
|
|
|
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
|
|
// Just to validate.
|
|
_, err = makeSherpaHandler("", false)
|
|
if err != nil {
|
|
pkglog.Fatalx("sherpa handler", err)
|
|
}
|
|
}
|
|
|
|
// Handler returns a handler for the webaccount endpoints, customized for the
|
|
// cookiePath.
|
|
func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
|
|
sh, err := makeSherpaHandler(cookiePath, isForwarded)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err != nil {
|
|
http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
handle(sh, isForwarded, w, r)
|
|
}
|
|
}
|
|
|
|
func xcheckf(ctx context.Context, err error, format string, args ...any) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
msg := fmt.Sprintf(format, args...)
|
|
errmsg := fmt.Sprintf("%s: %s", msg, err)
|
|
pkglog.WithContext(ctx).Errorx(msg, err)
|
|
code := "server:error"
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
code = "user:error"
|
|
}
|
|
panic(&sherpa.Error{Code: code, Message: errmsg})
|
|
}
|
|
|
|
func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
msg := fmt.Sprintf(format, args...)
|
|
errmsg := fmt.Sprintf("%s: %s", msg, err)
|
|
pkglog.WithContext(ctx).Errorx(msg, err)
|
|
panic(&sherpa.Error{Code: "user:error", Message: errmsg})
|
|
}
|
|
|
|
// Account exports web API functions for the account web interface. All its
|
|
// methods are exported under api/. Function calls require valid HTTP
|
|
// Authentication credentials of a user.
|
|
type Account struct {
|
|
cookiePath string // From listener, for setting authentication cookies.
|
|
isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
|
|
}
|
|
|
|
func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
|
|
log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
|
|
|
|
// Without authentication. The token is unguessable.
|
|
if r.URL.Path == "/importprogress" {
|
|
if r.Method != "GET" {
|
|
http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
token := q.Get("token")
|
|
if token == "" {
|
|
http.Error(w, "400 - bad request - missing token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
log.Error("internal error: ResponseWriter not a http.Flusher")
|
|
http.Error(w, "500 - internal error - cannot access underlying connection", 500)
|
|
return
|
|
}
|
|
|
|
l := importListener{token, make(chan importEvent, 100), make(chan bool, 1)}
|
|
importers.Register <- &l
|
|
ok = <-l.Register
|
|
if !ok {
|
|
http.Error(w, "400 - bad request - unknown token, import may have finished more than a minute ago", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer func() {
|
|
importers.Unregister <- &l
|
|
}()
|
|
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-cache")
|
|
_, err := w.Write([]byte(": keepalive\n\n"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
|
|
cctx := r.Context()
|
|
for {
|
|
select {
|
|
case e := <-l.Events:
|
|
_, err := w.Write(e.SSEMsg)
|
|
flusher.Flush()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
case <-cctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// HTML/JS can be retrieved without authentication.
|
|
if r.URL.Path == "/" {
|
|
switch r.Method {
|
|
case "GET", "HEAD":
|
|
webaccountFile.Serve(ctx, log, w, r)
|
|
default:
|
|
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
|
|
}
|
|
return
|
|
}
|
|
|
|
isAPI := strings.HasPrefix(r.URL.Path, "/api/")
|
|
// Only allow POST for calls, they will not work cross-domain without CORS.
|
|
if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
|
|
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var loginAddress, accName string
|
|
var sessionToken store.SessionToken
|
|
// All other URLs, except the login endpoint require some authentication.
|
|
if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
|
|
var ok bool
|
|
isExport := strings.HasPrefix(r.URL.Path, "/export/")
|
|
requireCSRF := isAPI || r.URL.Path == "/import" || isExport
|
|
accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webaccount", isForwarded, w, r, isAPI, requireCSRF, isExport)
|
|
if !ok {
|
|
// Response has been written already.
|
|
return
|
|
}
|
|
}
|
|
|
|
if isAPI {
|
|
reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
|
|
ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
|
|
apiHandler.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
switch r.URL.Path {
|
|
case "/export/mail-export-maildir.tgz", "/export/mail-export-maildir.zip", "/export/mail-export-mbox.tgz", "/export/mail-export-mbox.zip":
|
|
if r.Method != "POST" {
|
|
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
maildir := strings.Contains(r.URL.Path, "maildir")
|
|
tgz := strings.Contains(r.URL.Path, ".tgz")
|
|
|
|
acc, err := store.OpenAccount(log, accName)
|
|
if err != nil {
|
|
log.Errorx("open account for export", err)
|
|
http.Error(w, "500 - internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
err := acc.Close()
|
|
log.Check(err, "closing account")
|
|
}()
|
|
|
|
var archiver store.Archiver
|
|
if tgz {
|
|
// Don't tempt browsers to "helpfully" decompress.
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
gzw := gzip.NewWriter(w)
|
|
defer func() {
|
|
_ = gzw.Close()
|
|
}()
|
|
archiver = store.TarArchiver{Writer: tar.NewWriter(gzw)}
|
|
} else {
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
archiver = store.ZipArchiver{Writer: zip.NewWriter(w)}
|
|
}
|
|
defer func() {
|
|
err := archiver.Close()
|
|
log.Check(err, "exporting mail close")
|
|
}()
|
|
if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, maildir, ""); err != nil {
|
|
log.Errorx("exporting mail", err)
|
|
}
|
|
|
|
case "/import":
|
|
if r.Method != "POST" {
|
|
http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
f, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
if errors.Is(err, http.ErrMissingFile) {
|
|
http.Error(w, "400 - bad request - missing file", http.StatusBadRequest)
|
|
} else {
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
defer func() {
|
|
err := f.Close()
|
|
log.Check(err, "closing form file")
|
|
}()
|
|
skipMailboxPrefix := r.FormValue("skipMailboxPrefix")
|
|
tmpf, err := os.CreateTemp("", "mox-import")
|
|
if err != nil {
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if tmpf != nil {
|
|
store.CloseRemoveTempFile(log, tmpf, "upload")
|
|
}
|
|
}()
|
|
if _, err := io.Copy(tmpf, f); err != nil {
|
|
log.Errorx("copying import to temporary file", err)
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
token, isUserError, err := importStart(log, accName, tmpf, skipMailboxPrefix)
|
|
if err != nil {
|
|
log.Errorx("starting import", err, slog.Bool("usererror", isUserError))
|
|
if isUserError {
|
|
http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
|
|
} else {
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
tmpf = nil // importStart is now responsible for cleanup.
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(ImportProgress{Token: token})
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// ImportProgress is returned after uploading a file to import.
|
|
type ImportProgress struct {
|
|
// For fetching progress, or cancelling an import.
|
|
Token string
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
var requestInfoCtxKey ctxKey = "requestInfo"
|
|
|
|
type requestInfo struct {
|
|
LoginAddress string
|
|
AccountName string
|
|
SessionToken store.SessionToken
|
|
Response http.ResponseWriter
|
|
Request *http.Request // For Proto and TLS connection state during message submit.
|
|
}
|
|
|
|
// LoginPrep returns a login token, and also sets it as cookie. Both must be
|
|
// present in the call to Login.
|
|
func (w Account) LoginPrep(ctx context.Context) string {
|
|
log := pkglog.WithContext(ctx)
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
|
|
var data [8]byte
|
|
_, err := cryptorand.Read(data[:])
|
|
xcheckf(ctx, err, "generate token")
|
|
loginToken := base64.RawURLEncoding.EncodeToString(data[:])
|
|
|
|
webauth.LoginPrep(ctx, log, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
|
|
|
|
return loginToken
|
|
}
|
|
|
|
// Login returns a session token for the credentials, or fails with error code
|
|
// "user:badLogin". Call LoginPrep to get a loginToken.
|
|
func (w Account) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
|
|
log := pkglog.WithContext(ctx)
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
|
|
csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
|
|
if _, ok := err.(*sherpa.Error); ok {
|
|
panic(err)
|
|
}
|
|
xcheckf(ctx, err, "login")
|
|
return csrfToken
|
|
}
|
|
|
|
// Logout invalidates the session token.
|
|
func (w Account) Logout(ctx context.Context) {
|
|
log := pkglog.WithContext(ctx)
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
|
|
err := webauth.Logout(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
|
|
xcheckf(ctx, err, "logout")
|
|
}
|
|
|
|
// SetPassword saves a new password for the account, invalidating the previous password.
|
|
// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
|
|
// Password must be at least 8 characters.
|
|
func (Account) SetPassword(ctx context.Context, password string) {
|
|
log := pkglog.WithContext(ctx)
|
|
if len(password) < 8 {
|
|
panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
|
|
}
|
|
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
acc, err := store.OpenAccount(log, reqInfo.AccountName)
|
|
xcheckf(ctx, err, "open account")
|
|
defer func() {
|
|
err := acc.Close()
|
|
log.Check(err, "closing account")
|
|
}()
|
|
|
|
// Retrieve session, resetting password invalidates it.
|
|
ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
|
|
xcheckf(ctx, err, "get session")
|
|
|
|
err = acc.SetPassword(log, password)
|
|
xcheckf(ctx, err, "setting password")
|
|
|
|
// Session has been invalidated. Add it again.
|
|
err = store.SessionAddToken(ctx, log, &ls)
|
|
xcheckf(ctx, err, "restoring session after password reset")
|
|
}
|
|
|
|
// Account returns information about the account.
|
|
// StorageUsed is the sum of the sizes of all messages, in bytes.
|
|
// StorageLimit is the maximum storage that can be used, or 0 if there is no limit.
|
|
func (Account) Account(ctx context.Context) (account config.Account, storageUsed, storageLimit int64, suppressions []webapi.Suppression) {
|
|
log := pkglog.WithContext(ctx)
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
|
|
acc, err := store.OpenAccount(log, reqInfo.AccountName)
|
|
xcheckf(ctx, err, "open account")
|
|
defer func() {
|
|
err := acc.Close()
|
|
log.Check(err, "closing account")
|
|
}()
|
|
|
|
var accConf config.Account
|
|
acc.WithRLock(func() {
|
|
accConf, _ = acc.Conf()
|
|
|
|
storageLimit = acc.QuotaMessageSize()
|
|
err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
|
|
du := store.DiskUsage{ID: 1}
|
|
err := tx.Get(&du)
|
|
storageUsed = du.MessageSize
|
|
return err
|
|
})
|
|
xcheckf(ctx, err, "get disk usage")
|
|
})
|
|
|
|
suppressions, err = queue.SuppressionList(ctx, reqInfo.AccountName)
|
|
xcheckf(ctx, err, "list suppressions")
|
|
|
|
return accConf, storageUsed, storageLimit, suppressions
|
|
}
|
|
|
|
// AccountSaveFullName saves the full name (used as display name in email messages)
|
|
// for the account.
|
|
func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
acc.FullName = fullName
|
|
})
|
|
xcheckf(ctx, err, "saving account full name")
|
|
}
|
|
|
|
// DestinationSave updates a destination.
|
|
// OldDest is compared against the current destination. If it does not match, an
|
|
// error is returned. Otherwise newDest is saved and the configuration reloaded.
|
|
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
|
|
curDest, ok := conf.Destinations[destName]
|
|
if !ok {
|
|
xcheckuserf(ctx, errors.New("not found"), "looking up destination")
|
|
}
|
|
if !curDest.Equal(oldDest) {
|
|
xcheckuserf(ctx, errors.New("modified"), "checking stored destination")
|
|
}
|
|
|
|
// Keep fields we manage.
|
|
newDest.DMARCReports = curDest.DMARCReports
|
|
newDest.HostTLSReports = curDest.HostTLSReports
|
|
newDest.DomainTLSReports = curDest.DomainTLSReports
|
|
|
|
// Make copy of reference values.
|
|
nd := map[string]config.Destination{}
|
|
for dn, d := range conf.Destinations {
|
|
nd[dn] = d
|
|
}
|
|
nd[destName] = newDest
|
|
conf.Destinations = nd
|
|
})
|
|
xcheckf(ctx, err, "saving destination")
|
|
}
|
|
|
|
// ImportAbort aborts an import that is in progress. If the import exists and isn't
|
|
// finished, no changes will have been made by the import.
|
|
func (Account) ImportAbort(ctx context.Context, importToken string) error {
|
|
req := importAbortRequest{importToken, make(chan error)}
|
|
importers.Abort <- req
|
|
return <-req.Response
|
|
}
|
|
|
|
// Types exposes types not used in API method signatures, such as the import form upload.
|
|
func (Account) Types() (importProgress ImportProgress) {
|
|
return
|
|
}
|
|
|
|
// SuppressionList lists the addresses on the suppression list of this account.
|
|
func (Account) SuppressionList(ctx context.Context) (suppressions []webapi.Suppression) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
l, err := queue.SuppressionList(ctx, reqInfo.AccountName)
|
|
xcheckf(ctx, err, "list suppressions")
|
|
return l
|
|
}
|
|
|
|
// SuppressionAdd adds an email address to the suppression list.
|
|
func (Account) SuppressionAdd(ctx context.Context, address string, manual bool, reason string) (suppression webapi.Suppression) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
addr, err := smtp.ParseAddress(address)
|
|
xcheckuserf(ctx, err, "parsing address")
|
|
sup := webapi.Suppression{
|
|
Account: reqInfo.AccountName,
|
|
Manual: manual,
|
|
Reason: reason,
|
|
}
|
|
err = queue.SuppressionAdd(ctx, addr.Path(), &sup)
|
|
if err != nil && errors.Is(err, bstore.ErrUnique) {
|
|
xcheckuserf(ctx, err, "add suppression")
|
|
}
|
|
xcheckf(ctx, err, "add suppression")
|
|
return sup
|
|
}
|
|
|
|
// SuppressionRemove removes the email address from the suppression list.
|
|
func (Account) SuppressionRemove(ctx context.Context, address string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
addr, err := smtp.ParseAddress(address)
|
|
xcheckuserf(ctx, err, "parsing address")
|
|
err = queue.SuppressionRemove(ctx, reqInfo.AccountName, addr.Path())
|
|
if err != nil && err == bstore.ErrAbsent {
|
|
xcheckuserf(ctx, err, "remove suppression")
|
|
}
|
|
xcheckf(ctx, err, "remove suppression")
|
|
}
|
|
|
|
// OutgoingWebhookSave saves a new webhook url for outgoing deliveries. If url
|
|
// is empty, the webhook is disabled. If authorization is non-empty it is used for
|
|
// the Authorization header in HTTP requests. Events specifies the outgoing events
|
|
// to be delivered, or all if empty/nil.
|
|
func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
if url == "" {
|
|
acc.OutgoingWebhook = nil
|
|
} else {
|
|
acc.OutgoingWebhook = &config.OutgoingWebhook{URL: url, Authorization: authorization, Events: events}
|
|
}
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account outgoing webhook")
|
|
}
|
|
xcheckf(ctx, err, "saving account outgoing webhook")
|
|
}
|
|
|
|
// OutgoingWebhookTest makes a test webhook call to urlStr, with optional
|
|
// authorization. If the HTTP request is made this call will succeed also for
|
|
// non-2xx HTTP status codes.
|
|
func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization string, data webhook.Outgoing) (code int, response string, errmsg string) {
|
|
log := pkglog.WithContext(ctx)
|
|
|
|
xvalidURL(ctx, urlStr)
|
|
log.Debug("making webhook test call for outgoing message", slog.String("url", urlStr))
|
|
|
|
var b bytes.Buffer
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetIndent("", "\t")
|
|
enc.SetEscapeHTML(false)
|
|
err := enc.Encode(data)
|
|
xcheckf(ctx, err, "encoding outgoing webhook data")
|
|
|
|
code, response, err = queue.HookPost(ctx, log, 1, 1, urlStr, authorization, b.String())
|
|
if err != nil {
|
|
errmsg = err.Error()
|
|
}
|
|
log.Debugx("result for webhook test call for outgoing message", err, slog.Int("code", code), slog.String("response", response))
|
|
return code, response, errmsg
|
|
}
|
|
|
|
// IncomingWebhookSave saves a new webhook url for incoming deliveries. If url is
|
|
// empty, the webhook is disabled. If authorization is not empty, it is used in
|
|
// the Authorization header in requests.
|
|
func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
if url == "" {
|
|
acc.IncomingWebhook = nil
|
|
} else {
|
|
acc.IncomingWebhook = &config.IncomingWebhook{URL: url, Authorization: authorization}
|
|
}
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account incoming webhook")
|
|
}
|
|
xcheckf(ctx, err, "saving account incoming webhook")
|
|
}
|
|
|
|
func xvalidURL(ctx context.Context, s string) {
|
|
u, err := url.Parse(s)
|
|
xcheckuserf(ctx, err, "parsing url")
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
xcheckuserf(ctx, errors.New("scheme must be http or https"), "parsing url")
|
|
}
|
|
}
|
|
|
|
// IncomingWebhookTest makes a test webhook HTTP delivery request to urlStr,
|
|
// with optional authorization header. If the HTTP call is made, this function
|
|
// returns non-error regardless of HTTP status code.
|
|
func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization string, data webhook.Incoming) (code int, response string, errmsg string) {
|
|
log := pkglog.WithContext(ctx)
|
|
|
|
xvalidURL(ctx, urlStr)
|
|
log.Debug("making webhook test call for incoming message", slog.String("url", urlStr))
|
|
|
|
var b bytes.Buffer
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", "\t")
|
|
err := enc.Encode(data)
|
|
xcheckf(ctx, err, "encoding incoming webhook data")
|
|
code, response, err = queue.HookPost(ctx, log, 1, 1, urlStr, authorization, b.String())
|
|
if err != nil {
|
|
errmsg = err.Error()
|
|
}
|
|
log.Debugx("result for webhook test call for incoming message", err, slog.Int("code", code), slog.String("response", response))
|
|
return code, response, errmsg
|
|
}
|
|
|
|
// FromIDLoginAddressesSave saves new login addresses to enable unique SMTP
|
|
// MAIL FROM addresses ("fromid") for deliveries from the queue.
|
|
func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
acc.FromIDLoginAddresses = loginAddresses
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account fromid login addresses")
|
|
}
|
|
xcheckf(ctx, err, "saving account fromid login addresses")
|
|
}
|
|
|
|
// KeepRetiredPeriodsSave saves periods to save retired messages and webhooks.
|
|
func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod
|
|
acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account keep retired periods")
|
|
}
|
|
xcheckf(ctx, err, "saving account keep retired periods")
|
|
}
|
|
|
|
// AutomaticJunkFlagsSave saves settings for automatically marking messages as
|
|
// junk/nonjunk when moved to mailboxes matching certain regular expressions.
|
|
func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
acc.AutomaticJunkFlags = config.AutomaticJunkFlags{
|
|
Enabled: enabled,
|
|
JunkMailboxRegexp: junkRegexp,
|
|
NeutralMailboxRegexp: neutralRegexp,
|
|
NotJunkMailboxRegexp: notJunkRegexp,
|
|
}
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account automatic junk flags")
|
|
}
|
|
xcheckf(ctx, err, "saving account automatic junk flags")
|
|
}
|
|
|
|
// RejectsSave saves the RejectsMailbox and KeepRejects settings.
|
|
func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
|
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
|
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
|
|
acc.RejectsMailbox = mailbox
|
|
acc.KeepRejects = keep
|
|
})
|
|
if err != nil && errors.Is(err, mox.ErrConfig) {
|
|
xcheckuserf(ctx, err, "saving account rejects settings")
|
|
}
|
|
xcheckf(ctx, err, "saving account rejects settings")
|
|
}
|