mirror of
https://github.com/mjl-/mox.git
synced 2024-12-27 08:53:48 +03:00
09fcc49223
for applications to compose/send messages, receive delivery feedback, and maintain suppression lists. this is an alternative to applications using a library to compose messages, submitting those messages using smtp, and monitoring a mailbox with imap for DSNs, which can be processed into the equivalent of suppression lists. but you need to know about all these standards/protocols and find libraries. by using the webapi & webhooks, you just need a http & json library. unfortunately, there is no standard for these kinds of api, so mox has made up yet another one... matching incoming DSNs about deliveries to original outgoing messages requires keeping history of "retired" messages (delivered from the queue, either successfully or failed). this can be enabled per account. history is also useful for debugging deliveries. we now also keep history of each delivery attempt, accessible while still in the queue, and kept when a message is retired. the queue webadmin pages now also have pagination, to show potentially large history. a queue of webhook calls is now managed too. failures are retried similar to message deliveries. webhooks can also be saved to the retired list after completing. also configurable per account. messages can be sent with a "unique smtp mail from" address. this can only be used if the domain is configured with a localpart catchall separator such as "+". when enabled, a queued message gets assigned a random "fromid", which is added after the separator when sending. when DSNs are returned, they can be related to previously sent messages based on this fromid. in the future, we can implement matching on the "envid" used in the smtp dsn extension, or on the "message-id" of the message. using a fromid can be triggered by authenticating with a login email address that is configured as enabling fromid. suppression lists are automatically managed per account. if a delivery attempt results in certain smtp errors, the destination address is added to the suppression list. future messages queued for that recipient will immediately fail without a delivery attempt. suppression lists protect your mail server reputation. submitted messages can carry "extra" data through the queue and webhooks for outgoing deliveries. through webapi as a json object, through smtp submission as message headers of the form "x-mox-extra-<key>: value". to make it easy to test webapi/webhooks locally, the "localserve" mode actually puts messages in the queue. when it's time to deliver, it still won't do a full delivery attempt, but just delivers to the sender account. unless the recipient address has a special form, simulating a failure to deliver. admins now have more control over the queue. "hold rules" can be added to mark newly queued messages as "on hold", pausing delivery. rules can be about certain sender or recipient domains/addresses, or apply to all messages pausing the entire queue. also useful for (local) testing. new config options have been introduced. they are editable through the admin and/or account web interfaces. the webapi http endpoints are enabled for newly generated configs with the quickstart, and in localserve. existing configurations must explicitly enable the webapi in mox.conf. gopherwatch.org was created to dogfood this code. it initially used just the compose/smtpclient/imapclient mox packages to send messages and process delivery feedback. it will get a config option to use the mox webapi/webhooks instead. the gopherwatch code to use webapi/webhook is smaller and simpler, and developing that shaped development of the mox webapi/webhooks. for issue #31 by cuu508
663 lines
22 KiB
Go
663 lines
22 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 save 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")
|
|
}
|