improve queue management

- add option to put messages in the queue "on hold", preventing delivery
  attempts until taken off hold again.
- add "hold rules", to automatically mark some/all submitted messages as "on
  hold", e.g. from a specific account or to a specific domain.
- add operation to "fail" a message, causing a DSN to be delivered to the
  sender. previously we could only drop a message from the queue.
- update admin page & add new cli tools for these operations, with new
  filtering rules for selecting the messages to operate on. in the admin
  interface, add filtering and checkboxes to select a set of messages to operate
  on.
This commit is contained in:
Mechiel Lukkien 2024-03-18 08:50:42 +01:00
parent 79f1054b64
commit 40ade995a5
No known key found for this signature in database
19 changed files with 2554 additions and 565 deletions

264
ctl.go
View file

@ -294,6 +294,14 @@ func servectl(ctx context.Context, log mlog.Log, conn net.Conn, shutdown func())
} }
} }
func xparseFilters(ctl *ctl, s string) (f queue.Filter) {
dec := json.NewDecoder(strings.NewReader(s))
dec.DisallowUnknownFields()
err := dec.Decode(&f)
ctl.xcheck(err, "parsing filters")
return f
}
func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
log := ctl.log log := ctl.log
cmd := ctl.xread() cmd := ctl.xread()
@ -315,10 +323,10 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/ */
to := ctl.xread() to := ctl.xread()
a, addr, err := store.OpenEmail(ctl.log, to) a, addr, err := store.OpenEmail(log, to)
ctl.xcheck(err, "lookup destination address") ctl.xcheck(err, "lookup destination address")
msgFile, err := store.CreateMessageTemp(ctl.log, "ctl-deliver") msgFile, err := store.CreateMessageTemp(log, "ctl-deliver")
ctl.xcheck(err, "creating temporary message file") ctl.xcheck(err, "creating temporary message file")
defer store.CloseRemoveTempFile(log, msgFile, "deliver message") defer store.CloseRemoveTempFile(log, msgFile, "deliver message")
mw := message.NewWriter(msgFile) mw := message.NewWriter(msgFile)
@ -354,7 +362,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
account := ctl.xread() account := ctl.xread()
pw := ctl.xread() pw := ctl.xread()
acc, err := store.OpenAccount(ctl.log, account) acc, err := store.OpenAccount(log, account)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
if acc != nil { if acc != nil {
@ -363,25 +371,95 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
} }
}() }()
err = acc.SetPassword(ctl.log, pw) err = acc.SetPassword(log, pw)
ctl.xcheck(err, "setting password") ctl.xcheck(err, "setting password")
err = acc.Close() err = acc.Close()
ctl.xcheck(err, "closing account") ctl.xcheck(err, "closing account")
acc = nil acc = nil
ctl.xwriteok() ctl.xwriteok()
case "queue": case "queueholdruleslist":
/* protocol: /* protocol:
> "queue" > "queueholdruleslist"
< "ok" < "ok"
< stream < stream
*/ */
qmsgs, err := queue.List(ctx) l, err := queue.HoldRuleList(ctx)
ctl.xcheck(err, "listing hold rules")
ctl.xwriteok()
xw := ctl.writer()
fmt.Fprintln(xw, "hold rules:")
for _, hr := range l {
var elems []string
if hr.Account != "" {
elems = append(elems, fmt.Sprintf("account %q", hr.Account))
}
var zerodom dns.Domain
if hr.SenderDomain != zerodom {
elems = append(elems, fmt.Sprintf("sender domain %q", hr.SenderDomain.Name()))
}
if hr.RecipientDomain != zerodom {
elems = append(elems, fmt.Sprintf("sender domain %q", hr.RecipientDomain.Name()))
}
if len(elems) == 0 {
fmt.Fprintf(xw, "id %d: all messages\n", hr.ID)
} else {
fmt.Fprintf(xw, "id %d: %s\n", hr.ID, strings.Join(elems, ", "))
}
}
if len(l) == 0 {
fmt.Fprint(xw, "(none)\n")
}
xw.xclose()
case "queueholdrulesadd":
/* protocol:
> "queueholdrulesadd"
> account
> senderdomainstr
> recipientdomainstr
< "ok" or error
*/
var hr queue.HoldRule
hr.Account = ctl.xread()
senderdomstr := ctl.xread()
rcptdomstr := ctl.xread()
var err error
hr.SenderDomain, err = dns.ParseDomain(senderdomstr)
ctl.xcheck(err, "parsing sender domain")
hr.RecipientDomain, err = dns.ParseDomain(rcptdomstr)
ctl.xcheck(err, "parsing recipient domain")
hr, err = queue.HoldRuleAdd(ctx, log, hr)
ctl.xcheck(err, "add hold rule")
ctl.xwriteok()
case "queueholdrulesremove":
/* protocol:
> "queueholdrulesremove"
> id
< "ok" or error
*/
id, err := strconv.ParseInt(ctl.xread(), 10, 64)
ctl.xcheck(err, "parsing id")
err = queue.HoldRuleRemove(ctx, log, id)
ctl.xcheck(err, "remove hold rule")
ctl.xwriteok()
case "queuelist":
/* protocol:
> "queue"
> queuefilters as json
< "ok"
< stream
*/
fs := ctl.xread()
f := xparseFilters(ctl, fs)
qmsgs, err := queue.List(ctx, f)
ctl.xcheck(err, "listing queue") ctl.xcheck(err, "listing queue")
ctl.xwriteok() ctl.xwriteok()
xw := ctl.writer() xw := ctl.writer()
fmt.Fprintln(xw, "queue:") fmt.Fprintln(xw, "messages:")
for _, qm := range qmsgs { for _, qm := range qmsgs {
var lastAttempt string var lastAttempt string
if qm.LastAttempt != nil { if qm.LastAttempt != nil {
@ -390,63 +468,127 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
fmt.Fprintf(xw, "%5d %s from:%s to:%s next %s last %s error %q\n", qm.ID, qm.Queued.Format(time.RFC3339), qm.Sender().LogString(), qm.Recipient().LogString(), -time.Since(qm.NextAttempt).Round(time.Second), lastAttempt, qm.LastError) fmt.Fprintf(xw, "%5d %s from:%s to:%s next %s last %s error %q\n", qm.ID, qm.Queued.Format(time.RFC3339), qm.Sender().LogString(), qm.Recipient().LogString(), -time.Since(qm.NextAttempt).Round(time.Second), lastAttempt, qm.LastError)
} }
if len(qmsgs) == 0 { if len(qmsgs) == 0 {
fmt.Fprint(xw, "(empty)\n") fmt.Fprint(xw, "(none)\n")
} }
xw.xclose() xw.xclose()
case "queuekick": case "queueholdset":
/* protocol: /* protocol:
> "queuekick" > "queueholdset"
> id > queuefilters as json
> todomain > "true" or "false"
> recipient
> transport // if empty, transport is left unchanged; in future, we may want to differtiate between "leave unchanged" and "set to empty string".
< count
< "ok" or error < "ok" or error
< count
*/ */
idstr := ctl.xread() fs := ctl.xread()
todomain := ctl.xread() f := xparseFilters(ctl, fs)
recipient := ctl.xread() hold := ctl.xread() == "true"
transport := ctl.xread() count, err := queue.HoldSet(ctx, f, hold)
id, err := strconv.ParseInt(idstr, 10, 64) ctl.xcheck(err, "setting on hold status for messages")
if err != nil {
ctl.xwrite("0")
ctl.xcheck(err, "parsing id")
}
var xtransport *string
if transport != "" {
xtransport = &transport
}
count, err := queue.Kick(ctx, id, todomain, recipient, xtransport)
ctl.xcheck(err, "kicking queue")
ctl.xwrite(fmt.Sprintf("%d", count))
ctl.xwriteok() ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queueschedule":
/* protocol:
> "queueschedule"
> queuefilters as json
> relative to now
> duration
< "ok" or error
< count
*/
fs := ctl.xread()
f := xparseFilters(ctl, fs)
relnow := ctl.xread()
d, err := time.ParseDuration(ctl.xread())
ctl.xcheck(err, "parsing duration for next delivery attempt")
var count int
if relnow == "" {
count, err = queue.NextAttemptAdd(ctx, f, d)
} else {
count, err = queue.NextAttemptSet(ctx, f, time.Now().Add(d))
}
ctl.xcheck(err, "setting next delivery attempts in queue")
ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queuetransport":
/* protocol:
> "queuetransport"
> queuefilters as json
> transport
< "ok" or error
< count
*/
fs := ctl.xread()
f := xparseFilters(ctl, fs)
transport := ctl.xread()
count, err := queue.TransportSet(ctx, f, transport)
ctl.xcheck(err, "adding to next delivery attempts in queue")
ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queuerequiretls":
/* protocol:
> "queuerequiretls"
> queuefilters as json
> reqtls (empty string, "true" or "false")
< "ok" or error
< count
*/
fs := ctl.xread()
f := xparseFilters(ctl, fs)
reqtls := ctl.xread()
var req *bool
switch reqtls {
case "":
case "true":
v := true
req = &v
case "false":
v := false
req = &v
default:
ctl.xcheck(fmt.Errorf("unknown value %q", reqtls), "parsing value")
}
count, err := queue.RequireTLSSet(ctx, f, req)
ctl.xcheck(err, "setting tls requirements on messages in queue")
ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queuefail":
/* protocol:
> "queuefail"
> queuefilters as json
< "ok" or error
< count
*/
fs := ctl.xread()
f := xparseFilters(ctl, fs)
count, err := queue.Fail(ctx, log, f)
ctl.xcheck(err, "marking messages from queue as failed")
ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queuedrop": case "queuedrop":
/* protocol: /* protocol:
> "queuedrop" > "queuedrop"
> id > queuefilters as json
> todomain
> recipient
< count
< "ok" or error < "ok" or error
< count
*/ */
idstr := ctl.xread() fs := ctl.xread()
todomain := ctl.xread() f := xparseFilters(ctl, fs)
recipient := ctl.xread() count, err := queue.Drop(ctx, log, f)
id, err := strconv.ParseInt(idstr, 10, 64)
if err != nil {
ctl.xwrite("0")
ctl.xcheck(err, "parsing id")
}
count, err := queue.Drop(ctx, ctl.log, id, todomain, recipient)
ctl.xcheck(err, "dropping messages from queue") ctl.xcheck(err, "dropping messages from queue")
ctl.xwrite(fmt.Sprintf("%d", count))
ctl.xwriteok() ctl.xwriteok()
ctl.xwrite(fmt.Sprintf("%d", count))
case "queuedump": case "queuedump":
/* protocol: /* protocol:
@ -587,13 +729,13 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
pkg := ctl.xread() pkg := ctl.xread()
levelstr := ctl.xread() levelstr := ctl.xread()
if levelstr == "" { if levelstr == "" {
mox.Conf.LogLevelRemove(ctl.log, pkg) mox.Conf.LogLevelRemove(log, pkg)
} else { } else {
level, ok := mlog.Levels[levelstr] level, ok := mlog.Levels[levelstr]
if !ok { if !ok {
ctl.xerror("bad level") ctl.xerror("bad level")
} }
mox.Conf.LogLevelSet(ctl.log, pkg, level) mox.Conf.LogLevelSet(log, pkg, level)
} }
ctl.xwriteok() ctl.xwriteok()
@ -604,7 +746,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error < "ok" or error
*/ */
account := ctl.xread() account := ctl.xread()
acc, err := store.OpenAccount(ctl.log, account) acc, err := store.OpenAccount(log, account)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
if acc != nil { if acc != nil {
@ -629,7 +771,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
log.Check(err, "removing old junkfilter bloom filter file", slog.String("path", bloomPath)) log.Check(err, "removing old junkfilter bloom filter file", slog.String("path", bloomPath))
// Open junk filter, this creates new files. // Open junk filter, this creates new files.
jf, _, err := acc.OpenJunkFilter(ctx, ctl.log) jf, _, err := acc.OpenJunkFilter(ctx, log)
ctl.xcheck(err, "open new junk filter") ctl.xcheck(err, "open new junk filter")
defer func() { defer func() {
if jf == nil { if jf == nil {
@ -645,14 +787,14 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
err = q.ForEach(func(m store.Message) error { err = q.ForEach(func(m store.Message) error {
total++ total++
ok, err := acc.TrainMessage(ctx, ctl.log, jf, m) ok, err := acc.TrainMessage(ctx, log, jf, m)
if ok { if ok {
trained++ trained++
} }
return err return err
}) })
ctl.xcheck(err, "training messages") ctl.xcheck(err, "training messages")
ctl.log.Info("retrained messages", slog.Int("total", total), slog.Int("trained", trained)) log.Info("retrained messages", slog.Int("total", total), slog.Int("trained", trained))
// Close junk filter, marking success. // Close junk filter, marking success.
err = jf.Close() err = jf.Close()
@ -669,7 +811,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< stream < stream
*/ */
account := ctl.xread() account := ctl.xread()
acc, err := store.OpenAccount(ctl.log, account) acc, err := store.OpenAccount(log, account)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
if acc != nil { if acc != nil {
@ -744,7 +886,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
const batchSize = 10000 const batchSize = 10000
xfixmsgsize := func(accName string) { xfixmsgsize := func(accName string) {
acc, err := store.OpenAccount(ctl.log, accName) acc, err := store.OpenAccount(log, accName)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
err := acc.Close() err := acc.Close()
@ -879,7 +1021,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
const batchSize = 100 const batchSize = 100
xreparseAccount := func(accName string) { xreparseAccount := func(accName string) {
acc, err := store.OpenAccount(ctl.log, accName) acc, err := store.OpenAccount(log, accName)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
err := acc.Close() err := acc.Close()
@ -955,7 +1097,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
w := ctl.writer() w := ctl.writer()
xreassignThreads := func(accName string) { xreassignThreads := func(accName string) {
acc, err := store.OpenAccount(ctl.log, accName) acc, err := store.OpenAccount(log, accName)
ctl.xcheck(err, "open account") ctl.xcheck(err, "open account")
defer func() { defer func() {
err := acc.Close() err := acc.Close()
@ -963,20 +1105,20 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
}() }()
// We don't want to step on an existing upgrade process. // We don't want to step on an existing upgrade process.
err = acc.ThreadingWait(ctl.log) err = acc.ThreadingWait(log)
ctl.xcheck(err, "waiting for threading upgrade to finish") ctl.xcheck(err, "waiting for threading upgrade to finish")
// todo: should we try to continue if the threading upgrade failed? only if there is a chance it will succeed this time... // todo: should we try to continue if the threading upgrade failed? only if there is a chance it will succeed this time...
// todo: reassigning isn't atomic (in a single transaction), ideally it would be (bstore would need to be able to handle large updates). // todo: reassigning isn't atomic (in a single transaction), ideally it would be (bstore would need to be able to handle large updates).
const batchSize = 50000 const batchSize = 50000
total, err := acc.ResetThreading(ctx, ctl.log, batchSize, true) total, err := acc.ResetThreading(ctx, log, batchSize, true)
ctl.xcheck(err, "resetting threading fields") ctl.xcheck(err, "resetting threading fields")
_, err = fmt.Fprintf(w, "New thread base subject assigned to %d message(s), starting to reassign threads...\n", total) _, err = fmt.Fprintf(w, "New thread base subject assigned to %d message(s), starting to reassign threads...\n", total)
ctl.xcheck(err, "write") ctl.xcheck(err, "write")
// Assign threads again. Ideally we would do this in a single transaction, but // Assign threads again. Ideally we would do this in a single transaction, but
// bstore/boltdb cannot handle so many pending changes, so we set a high batchsize. // bstore/boltdb cannot handle so many pending changes, so we set a high batchsize.
err = acc.AssignThreads(ctx, ctl.log, nil, 0, 50000, w) err = acc.AssignThreads(ctx, log, nil, 0, 50000, w)
ctl.xcheck(err, "reassign threads") ctl.xcheck(err, "reassign threads")
_, err = fmt.Fprintf(w, "Threads reassigned. You should invalidate messages stored at imap clients with the \"mox bumpuidvalidity account [mailbox]\" command.\n") _, err = fmt.Fprintf(w, "Threads reassigned. You should invalidate messages stored at imap clients with the \"mox bumpuidvalidity account [mailbox]\" command.\n")

View file

@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/mjl-/mox/dmarcdb" "github.com/mjl-/mox/dmarcdb"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
@ -67,19 +68,66 @@ func TestCtl(t *testing.T) {
err := queue.Init() err := queue.Init()
tcheck(t, err, "queue init") tcheck(t, err, "queue init")
// "queue"
testctl(func(ctl *ctl) { testctl(func(ctl *ctl) {
ctlcmdQueueList(ctl) ctlcmdQueueHoldrulesList(ctl)
}) })
// "queuekick" // All messages.
testctl(func(ctl *ctl) { testctl(func(ctl *ctl) {
ctlcmdQueueKick(ctl, 0, "", "", "") ctlcmdQueueHoldrulesAdd(ctl, "", "", "")
})
testctl(func(ctl *ctl) {
ctlcmdQueueHoldrulesAdd(ctl, "mjl", "", "")
})
testctl(func(ctl *ctl) {
ctlcmdQueueHoldrulesAdd(ctl, "", "☺.mox.example", "")
})
testctl(func(ctl *ctl) {
ctlcmdQueueHoldrulesAdd(ctl, "mox", "☺.mox.example", "example.com")
})
testctl(func(ctl *ctl) {
ctlcmdQueueHoldrulesRemove(ctl, 1)
})
// Has entries now.
testctl(func(ctl *ctl) {
ctlcmdQueueHoldrulesList(ctl)
})
// "queuelist"
testctl(func(ctl *ctl) {
ctlcmdQueueList(ctl, queue.Filter{})
})
// "queueholdset"
testctl(func(ctl *ctl) {
ctlcmdQueueHoldSet(ctl, queue.Filter{}, true)
})
// "queueschedule"
testctl(func(ctl *ctl) {
ctlcmdQueueSchedule(ctl, queue.Filter{}, true, time.Minute)
})
// "queuetransport"
testctl(func(ctl *ctl) {
ctlcmdQueueTransport(ctl, queue.Filter{}, "socks")
})
// "queuerequiretls"
testctl(func(ctl *ctl) {
ctlcmdQueueRequireTLS(ctl, queue.Filter{}, nil)
})
// "queuefail"
testctl(func(ctl *ctl) {
ctlcmdQueueFail(ctl, queue.Filter{})
}) })
// "queuedrop" // "queuedrop"
testctl(func(ctl *ctl) { testctl(func(ctl *ctl) {
ctlcmdQueueDrop(ctl, 0, "", "") ctlcmdQueueDrop(ctl, queue.Filter{})
}) })
// no "queuedump", we don't have a message to dump, and the commands exits without a message. // no "queuedump", we don't have a message to dump, and the commands exits without a message.

272
doc.go
View file

@ -25,9 +25,17 @@ any parameters. Followed by the help and usage information for each command.
mox setaccountpassword account mox setaccountpassword account
mox setadminpassword mox setadminpassword
mox loglevels [level [pkg]] mox loglevels [level [pkg]]
mox queue list mox queue holdrules list
mox queue kick [-id id] [-todomain domain] [-recipient address] [-transport transport] mox queue holdrules add [ruleflags]
mox queue drop [-id id] [-todomain domain] [-recipient address] mox queue holdrules remove ruleid
mox queue list [filterflags]
mox queue hold [filterflags]
mox queue unhold [filterflags]
mox queue schedule [filterflags] duration
mox queue transport [filterflags] transport
mox queue requiretls [filterflags] {yes | no | default}
mox queue fail [filterflags]
mox queue drop [filterflags]
mox queue dump id mox queue dump id
mox import maildir accountname mailboxname maildir mox import maildir accountname mailboxname maildir
mox import mbox accountname mailboxname mbox mox import mbox accountname mailboxname mbox
@ -195,37 +203,227 @@ Valid labels: error, info, debug, trace, traceauth, tracedata.
usage: mox loglevels [level [pkg]] usage: mox loglevels [level [pkg]]
# mox queue holdrules list
List hold rules for the delivery queue.
Messages submitted to the queue that match a hold rule will be marked as on hold
and not scheduled for delivery.
usage: mox queue holdrules list
# mox queue holdrules add
Add hold rule for the delivery queue.
Add a hold rule to mark matching newly submitted messages as on hold. Set the
matching rules with the flags. Don't specify any flags to match all submitted
messages.
usage: mox queue holdrules add [ruleflags]
-account string
account submitting the message
-recipientdom string
recipient domain
-senderdom string
sender domain
# mox queue holdrules remove
Remove hold rule for the delivery queue.
Remove a hold rule by its id.
usage: mox queue holdrules remove ruleid
# mox queue list # mox queue list
List messages in the delivery queue. List matching messages in the delivery queue.
This prints the message with its ID, last and next delivery attempts, last Prints the message with its ID, last and next delivery attempts, last error.
error.
usage: mox queue list usage: mox queue list [filterflags]
-account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue kick # mox queue hold
Schedule matching messages in the queue for immediate delivery. Mark matching messages on hold.
Messages deliveries are normally attempted with exponential backoff. The first Messages that are on hold are not delivered until marked as off hold again, or
retry after 7.5 minutes, and doubling each time. Kicking messages sets their otherwise handled by the admin.
next scheduled attempt to now, it can cause delivery to fail earlier than
without rescheduling.
With the -transport flag, future delivery attempts are done using the specified usage: mox queue hold [filterflags]
transport. Transports can be configured in mox.conf, e.g. to submit to a remote -account string
queue over SMTP. account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
usage: mox queue kick [-id id] [-todomain domain] [-recipient address] [-transport transport] # mox queue unhold
-id int
id of message in queue Mark matching messages off hold.
-recipient string
recipient email address Once off hold, messages can be delivered according to their current next
-todomain string delivery attempt. See the "queue schedule" command.
destination domain of messages
-transport string usage: mox queue unhold [filterflags]
transport to use for the next delivery -account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue schedule
Change next delivery attempt for matching messages.
The next delivery attempt is adjusted by the duration parameter. If the -now
flag is set, the new delivery attempt is set to the duration added to the
current time, instead of added to the current scheduled time.
Schedule immediate delivery with "mox queue schedule -now 0".
usage: mox queue schedule [filterflags] duration
-account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-now
schedule for duration relative to current time instead of relative to current next delivery attempt for messages
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue transport
Set transport for matching messages.
By default, the routing rules determine how a message is delivered. The default
and common case is direct delivery with SMTP. Messages can get a previously
configured transport assigned to use for delivery, e.g. using submission to
another mail server or with connections over a SOCKS proxy.
usage: mox queue transport [filterflags] transport
-account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue requiretls
Set TLS requirements for delivery of matching messages.
Value "yes" is handled as if the RequireTLS extension was specified during
submission.
Value "no" is handled as if the message has a header "TLS-Required: No". This
header is not added by the queue. If messages without this header are relayed
through other mail servers they will apply their own default TLS policy.
Value "default" is the default behaviour, currently for unverified opportunistic
TLS.
usage: mox queue requiretls [filterflags] {yes | no | default}
-account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue fail
Fail delivery of matching messages, delivering DSNs.
Failing a message is handled similar to how delivery is given up after all
delivery attempts failed. The DSN (delivery status notification) message
contains a line saying the message was canceled by the admin.
usage: mox queue fail [filterflags]
-account string
account that queued the message
-from string
from address of message, use "@example.com" to match all messages for a domain
-hold value
true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue drop # mox queue drop
@ -234,13 +432,23 @@ Remove matching messages from the queue.
Dangerous operation, this completely removes the message. If you want to store Dangerous operation, this completely removes the message. If you want to store
the message, use "queue dump" before removing. the message, use "queue dump" before removing.
usage: mox queue drop [-id id] [-todomain domain] [-recipient address] usage: mox queue drop [filterflags]
-id int -account string
id of message in queue account that queued the message
-recipient string -from string
recipient email address from address of message, use "@example.com" to match all messages for a domain
-todomain string -hold value
destination domain of messages true or false, whether to match only messages that are (not) on hold
-ids value
comma-separated list of message IDs
-nextattempt string
filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)
-submitted string
filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)
-to string
recipient address of message, use "@example.com" to match all messages for a domain
-transport value
transport to use for messages, empty string sets the default behaviour
# mox queue dump # mox queue dump

1
lib.ts
View file

@ -215,6 +215,7 @@ const attr = {
method: (s: string) => _attr('method', s), method: (s: string) => _attr('method', s),
autocomplete: (s: string) => _attr('autocomplete', s), autocomplete: (s: string) => _attr('autocomplete', s),
list: (s: string) => _attr('list', s), list: (s: string) => _attr('list', s),
form: (s: string) => _attr('form', s),
} }
const style = (x: {[k: string]: string | number}) => { return {_styles: x}} const style = (x: {[k: string]: string | number}) => { return {_styles: x}}
const prop = (x: {[k: string]: any}) => { return {_props: x}} const prop = (x: {[k: string]: any}) => { return {_props: x}}

128
main.go
View file

@ -99,8 +99,16 @@ var commands = []struct {
{"setaccountpassword", cmdSetaccountpassword}, {"setaccountpassword", cmdSetaccountpassword},
{"setadminpassword", cmdSetadminpassword}, {"setadminpassword", cmdSetadminpassword},
{"loglevels", cmdLoglevels}, {"loglevels", cmdLoglevels},
{"queue holdrules list", cmdQueueHoldrulesList},
{"queue holdrules add", cmdQueueHoldrulesAdd},
{"queue holdrules remove", cmdQueueHoldrulesRemove},
{"queue list", cmdQueueList}, {"queue list", cmdQueueList},
{"queue kick", cmdQueueKick}, {"queue hold", cmdQueueHold},
{"queue unhold", cmdQueueUnhold},
{"queue schedule", cmdQueueSchedule},
{"queue transport", cmdQueueTransport},
{"queue requiretls", cmdQueueRequireTLS},
{"queue fail", cmdQueueFail},
{"queue drop", cmdQueueDrop}, {"queue drop", cmdQueueDrop},
{"queue dump", cmdQueueDump}, {"queue dump", cmdQueueDump},
{"import maildir", cmdImportMaildir}, {"import maildir", cmdImportMaildir},
@ -1334,124 +1342,6 @@ func ctlcmdDeliver(ctl *ctl, address string) {
} }
} }
func cmdQueueList(c *cmd) {
c.help = `List messages in the delivery queue.
This prints the message with its ID, last and next delivery attempts, last
error.
`
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueList(xctl())
}
func ctlcmdQueueList(ctl *ctl) {
ctl.xwrite("queue")
ctl.xreadok()
if _, err := io.Copy(os.Stdout, ctl.reader()); err != nil {
log.Fatalf("%s", err)
}
}
func cmdQueueKick(c *cmd) {
c.params = "[-id id] [-todomain domain] [-recipient address] [-transport transport]"
c.help = `Schedule matching messages in the queue for immediate delivery.
Messages deliveries are normally attempted with exponential backoff. The first
retry after 7.5 minutes, and doubling each time. Kicking messages sets their
next scheduled attempt to now, it can cause delivery to fail earlier than
without rescheduling.
With the -transport flag, future delivery attempts are done using the specified
transport. Transports can be configured in mox.conf, e.g. to submit to a remote
queue over SMTP.
`
var id int64
var todomain, recipient, transport string
c.flag.Int64Var(&id, "id", 0, "id of message in queue")
c.flag.StringVar(&todomain, "todomain", "", "destination domain of messages")
c.flag.StringVar(&recipient, "recipient", "", "recipient email address")
c.flag.StringVar(&transport, "transport", "", "transport to use for the next delivery")
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueKick(xctl(), id, todomain, recipient, transport)
}
func ctlcmdQueueKick(ctl *ctl, id int64, todomain, recipient, transport string) {
ctl.xwrite("queuekick")
ctl.xwrite(fmt.Sprintf("%d", id))
ctl.xwrite(todomain)
ctl.xwrite(recipient)
ctl.xwrite(transport)
count := ctl.xread()
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages scheduled\n", count)
} else {
log.Fatalf("scheduling messages for immediate delivery: %s", line)
}
}
func cmdQueueDrop(c *cmd) {
c.params = "[-id id] [-todomain domain] [-recipient address]"
c.help = `Remove matching messages from the queue.
Dangerous operation, this completely removes the message. If you want to store
the message, use "queue dump" before removing.
`
var id int64
var todomain, recipient string
c.flag.Int64Var(&id, "id", 0, "id of message in queue")
c.flag.StringVar(&todomain, "todomain", "", "destination domain of messages")
c.flag.StringVar(&recipient, "recipient", "", "recipient email address")
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueDrop(xctl(), id, todomain, recipient)
}
func ctlcmdQueueDrop(ctl *ctl, id int64, todomain, recipient string) {
ctl.xwrite("queuedrop")
ctl.xwrite(fmt.Sprintf("%d", id))
ctl.xwrite(todomain)
ctl.xwrite(recipient)
count := ctl.xread()
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages dropped\n", count)
} else {
log.Fatalf("scheduling messages for immediate delivery: %s", line)
}
}
func cmdQueueDump(c *cmd) {
c.params = "id"
c.help = `Dump a message from the queue.
The message is printed to stdout and is in standard internet mail format.
`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueDump(xctl(), args[0])
}
func ctlcmdQueueDump(ctl *ctl, id string) {
ctl.xwrite("queuedump")
ctl.xwrite(id)
ctl.xreadok()
if _, err := io.Copy(os.Stdout, ctl.reader()); err != nil {
log.Fatalf("%s", err)
}
}
func cmdDKIMGenrsa(c *cmd) { func cmdDKIMGenrsa(c *cmd) {
c.params = ">$selector._domainkey.$domain.rsa2048.privatekey.pkcs8.pem" c.params = ">$selector._domainkey.$domain.rsa2048.privatekey.pkcs8.pem"
c.help = `Generate a new 2048 bit RSA private key for use with DKIM. c.help = `Generate a new 2048 bit RSA private key for use with DKIM.

View file

@ -28,6 +28,12 @@ groups:
annotations: annotations:
summary: http 5xx responses from webserver summary: http 5xx responses from webserver
- alert: mox-queue-hold
expr: mox_queue_hold > 0
for: 2h
annotations:
summary: messages on hold in queue for at least two hours
- alert: mox-submission-errors - alert: mox-submission-errors
expr: increase(mox_smtpserver_submission_total{result=~".*error"}[1h]) > 0 expr: increase(mox_smtpserver_submission_total{result=~".*error"}[1h]) > 0
annotations: annotations:

405
queue.go Normal file
View file

@ -0,0 +1,405 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/mjl-/mox/queue"
)
func cmdQueueHoldrulesList(c *cmd) {
c.help = `List hold rules for the delivery queue.
Messages submitted to the queue that match a hold rule will be marked as on hold
and not scheduled for delivery.
`
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueHoldrulesList(xctl())
}
func ctlcmdQueueHoldrulesList(ctl *ctl) {
ctl.xwrite("queueholdruleslist")
ctl.xreadok()
if _, err := io.Copy(os.Stdout, ctl.reader()); err != nil {
log.Fatalf("%s", err)
}
}
func cmdQueueHoldrulesAdd(c *cmd) {
c.params = "[ruleflags]"
c.help = `Add hold rule for the delivery queue.
Add a hold rule to mark matching newly submitted messages as on hold. Set the
matching rules with the flags. Don't specify any flags to match all submitted
messages.
`
var account, senderDomain, recipientDomain string
c.flag.StringVar(&account, "account", "", "account submitting the message")
c.flag.StringVar(&senderDomain, "senderdom", "", "sender domain")
c.flag.StringVar(&recipientDomain, "recipientdom", "", "recipient domain")
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueHoldrulesAdd(xctl(), account, senderDomain, recipientDomain)
}
func ctlcmdQueueHoldrulesAdd(ctl *ctl, account, senderDomain, recipientDomain string) {
ctl.xwrite("queueholdrulesadd")
ctl.xwrite(account)
ctl.xwrite(senderDomain)
ctl.xwrite(recipientDomain)
ctl.xreadok()
}
func cmdQueueHoldrulesRemove(c *cmd) {
c.params = "ruleid"
c.help = `Remove hold rule for the delivery queue.
Remove a hold rule by its id.
`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
id, err := strconv.ParseInt(args[0], 10, 64)
xcheckf(err, "parsing id")
mustLoadConfig()
ctlcmdQueueHoldrulesRemove(xctl(), id)
}
func ctlcmdQueueHoldrulesRemove(ctl *ctl, id int64) {
ctl.xwrite("queueholdrulesremove")
ctl.xwrite(fmt.Sprintf("%d", id))
ctl.xreadok()
}
// flagFilter is used by many of the queue commands to accept flags for filtering
// the messages the operation applies to.
func flagFilter(fs *flag.FlagSet, f *queue.Filter) {
fs.Func("ids", "comma-separated list of message IDs", func(v string) error {
for _, s := range strings.Split(v, ",") {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return err
}
f.IDs = append(f.IDs, id)
}
return nil
})
fs.StringVar(&f.Account, "account", "", "account that queued the message")
fs.StringVar(&f.From, "from", "", `from address of message, use "@example.com" to match all messages for a domain`)
fs.StringVar(&f.To, "to", "", `recipient address of message, use "@example.com" to match all messages for a domain`)
fs.StringVar(&f.Submitted, "submitted", "", `filter by time of submission relative to now, value must start with "<" (before now) or ">" (after now)`)
fs.StringVar(&f.NextAttempt, "nextattempt", "", `filter by time of next delivery attempt relative to now, value must start with "<" (before now) or ">" (after now)`)
fs.Func("transport", "transport to use for messages, empty string sets the default behaviour", func(v string) error {
f.Transport = &v
return nil
})
fs.Func("hold", "true or false, whether to match only messages that are (not) on hold", func(v string) error {
var hold bool
if v == "true" {
hold = true
} else if v == "false" {
hold = false
} else {
return fmt.Errorf("bad value %q", v)
}
f.Hold = &hold
return nil
})
}
func cmdQueueList(c *cmd) {
c.params = "[filterflags]"
c.help = `List matching messages in the delivery queue.
Prints the message with its ID, last and next delivery attempts, last error.
`
var f queue.Filter
flagFilter(c.flag, &f)
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueList(xctl(), f)
}
func xctlwritequeuefilter(ctl *ctl, f queue.Filter) {
fbuf, err := json.Marshal(f)
xcheckf(err, "marshal filter")
ctl.xwrite(string(fbuf))
}
func ctlcmdQueueList(ctl *ctl, f queue.Filter) {
ctl.xwrite("queuelist")
xctlwritequeuefilter(ctl, f)
ctl.xreadok()
if _, err := io.Copy(os.Stdout, ctl.reader()); err != nil {
log.Fatalf("%s", err)
}
}
func cmdQueueHold(c *cmd) {
c.params = "[filterflags]"
c.help = `Mark matching messages on hold.
Messages that are on hold are not delivered until marked as off hold again, or
otherwise handled by the admin.
`
var f queue.Filter
flagFilter(c.flag, &f)
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueHoldSet(xctl(), f, true)
}
func cmdQueueUnhold(c *cmd) {
c.params = "[filterflags]"
c.help = `Mark matching messages off hold.
Once off hold, messages can be delivered according to their current next
delivery attempt. See the "queue schedule" command.
`
var f queue.Filter
flagFilter(c.flag, &f)
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueHoldSet(xctl(), f, false)
}
func ctlcmdQueueHoldSet(ctl *ctl, f queue.Filter, hold bool) {
ctl.xwrite("queueholdset")
xctlwritequeuefilter(ctl, f)
if hold {
ctl.xwrite("true")
} else {
ctl.xwrite("false")
}
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages changed\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueSchedule(c *cmd) {
c.params = "[filterflags] duration"
c.help = `Change next delivery attempt for matching messages.
The next delivery attempt is adjusted by the duration parameter. If the -now
flag is set, the new delivery attempt is set to the duration added to the
current time, instead of added to the current scheduled time.
Schedule immediate delivery with "mox queue schedule -now 0".
`
var fromNow bool
c.flag.BoolVar(&fromNow, "now", false, "schedule for duration relative to current time instead of relative to current next delivery attempt for messages")
var f queue.Filter
flagFilter(c.flag, &f)
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
d, err := time.ParseDuration(args[0])
xcheckf(err, "parsing duration %q", args[0])
mustLoadConfig()
ctlcmdQueueSchedule(xctl(), f, fromNow, d)
}
func ctlcmdQueueSchedule(ctl *ctl, f queue.Filter, fromNow bool, d time.Duration) {
ctl.xwrite("queueschedule")
xctlwritequeuefilter(ctl, f)
if fromNow {
ctl.xwrite("yes")
} else {
ctl.xwrite("")
}
ctl.xwrite(d.String())
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages rescheduled\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueTransport(c *cmd) {
c.params = "[filterflags] transport"
c.help = `Set transport for matching messages.
By default, the routing rules determine how a message is delivered. The default
and common case is direct delivery with SMTP. Messages can get a previously
configured transport assigned to use for delivery, e.g. using submission to
another mail server or with connections over a SOCKS proxy.
`
var f queue.Filter
flagFilter(c.flag, &f)
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueTransport(xctl(), f, args[0])
}
func ctlcmdQueueTransport(ctl *ctl, f queue.Filter, transport string) {
ctl.xwrite("queuetransport")
xctlwritequeuefilter(ctl, f)
ctl.xwrite(transport)
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages changed\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueRequireTLS(c *cmd) {
c.params = "[filterflags] {yes | no | default}"
c.help = `Set TLS requirements for delivery of matching messages.
Value "yes" is handled as if the RequireTLS extension was specified during
submission.
Value "no" is handled as if the message has a header "TLS-Required: No". This
header is not added by the queue. If messages without this header are relayed
through other mail servers they will apply their own default TLS policy.
Value "default" is the default behaviour, currently for unverified opportunistic
TLS.
`
var f queue.Filter
flagFilter(c.flag, &f)
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
var tlsreq *bool
switch args[0] {
case "yes":
v := true
tlsreq = &v
case "no":
v := false
tlsreq = &v
case "default":
default:
c.Usage()
}
mustLoadConfig()
ctlcmdQueueRequireTLS(xctl(), f, tlsreq)
}
func ctlcmdQueueRequireTLS(ctl *ctl, f queue.Filter, tlsreq *bool) {
ctl.xwrite("queuerequiretls")
xctlwritequeuefilter(ctl, f)
var req string
if tlsreq == nil {
req = ""
} else if *tlsreq {
req = "true"
} else {
req = "false"
}
ctl.xwrite(req)
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages changed\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueFail(c *cmd) {
c.params = "[filterflags]"
c.help = `Fail delivery of matching messages, delivering DSNs.
Failing a message is handled similar to how delivery is given up after all
delivery attempts failed. The DSN (delivery status notification) message
contains a line saying the message was canceled by the admin.
`
var f queue.Filter
flagFilter(c.flag, &f)
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueFail(xctl(), f)
}
func ctlcmdQueueFail(ctl *ctl, f queue.Filter) {
ctl.xwrite("queuefail")
xctlwritequeuefilter(ctl, f)
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages marked as failed\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueDrop(c *cmd) {
c.params = "[filterflags]"
c.help = `Remove matching messages from the queue.
Dangerous operation, this completely removes the message. If you want to store
the message, use "queue dump" before removing.
`
var f queue.Filter
flagFilter(c.flag, &f)
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueDrop(xctl(), f)
}
func ctlcmdQueueDrop(ctl *ctl, f queue.Filter) {
ctl.xwrite("queuedrop")
xctlwritequeuefilter(ctl, f)
line := ctl.xread()
if line == "ok" {
fmt.Printf("%s messages dropped\n", ctl.xread())
} else {
log.Fatalf("%s", line)
}
}
func cmdQueueDump(c *cmd) {
c.params = "id"
c.help = `Dump a message from the queue.
The message is printed to stdout and is in standard internet mail format.
`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdQueueDump(xctl(), args[0])
}
func ctlcmdQueueDump(ctl *ctl, id string) {
ctl.xwrite("queuedump")
ctl.xwrite(id)
ctl.xreadok()
if _, err := io.Copy(os.Stdout, ctl.reader()); err != nil {
log.Fatalf("%s", err)
}
}

View file

@ -61,11 +61,17 @@ var (
"result", // ok, timeout, canceled, temperror, permerror, error "result", // ok, timeout, canceled, temperror, permerror, error
}, },
) )
metricHold = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "mox_queue_hold",
Help: "Messages in queue that are on hold.",
},
)
) )
var jitter = mox.NewPseudoRand() var jitter = mox.NewPseudoRand()
var DBTypes = []any{Msg{}} // Types stored in DB. var DBTypes = []any{Msg{}, HoldRule{}} // Types stored in DB.
var DB *bstore.DB // Exported for making backups. var DB *bstore.DB // Exported for making backups.
// Allow requesting delivery starting from up to this interval from time of submission. // Allow requesting delivery starting from up to this interval from time of submission.
@ -74,6 +80,27 @@ const FutureReleaseIntervalMax = 60 * 24 * time.Hour
// Set for mox localserve, to prevent queueing. // Set for mox localserve, to prevent queueing.
var Localserve bool var Localserve bool
// HoldRule is a set of conditions that cause a matching message to be marked as on
// hold when it is queued. All-empty conditions matches all messages, effectively
// pausing the entire queue.
type HoldRule struct {
ID int64
Account string
SenderDomain dns.Domain
RecipientDomain dns.Domain
SenderDomainStr string // Unicode.
RecipientDomainStr string // Unicode.
}
func (pr HoldRule) All() bool {
pr.ID = 0
return pr == HoldRule{}
}
func (pr HoldRule) matches(m Msg) bool {
return pr.All() || pr.Account == m.SenderAccount || pr.SenderDomainStr == m.SenderDomainStr || pr.RecipientDomainStr == m.RecipientDomainStr
}
// Msg is a message in the queue. // Msg is a message in the queue.
// //
// Use MakeMsg to make a message with fields that Add needs. Add will further set // Use MakeMsg to make a message with fields that Add needs. Add will further set
@ -89,12 +116,14 @@ type Msg struct {
BaseID int64 `bstore:"index"` BaseID int64 `bstore:"index"`
Queued time.Time `bstore:"default now"` Queued time.Time `bstore:"default now"`
Hold bool // If set, delivery won't be attempted.
SenderAccount string // Failures are delivered back to this local account. Also used for routing. SenderAccount string // Failures are delivered back to this local account. Also used for routing.
SenderLocalpart smtp.Localpart // Should be a local user and domain. SenderLocalpart smtp.Localpart // Should be a local user and domain.
SenderDomain dns.IPDomain SenderDomain dns.IPDomain
SenderDomainStr string // For filtering, unicode.
RecipientLocalpart smtp.Localpart // Typically a remote user and domain. RecipientLocalpart smtp.Localpart // Typically a remote user and domain.
RecipientDomain dns.IPDomain RecipientDomain dns.IPDomain
RecipientDomainStr string // For filtering. RecipientDomainStr string // For filtering, unicode.
Attempts int // Next attempt is based on last attempt and exponential back off based on attempts. Attempts int // Next attempt is based on last attempt and exponential back off based on attempts.
MaxAttempts int // Max number of attempts before giving up. If 0, then the default of 8 attempts is used instead. MaxAttempts int // Max number of attempts before giving up. If 0, then the default of 8 attempts is used instead.
DialedIPs map[string][]net.IP // For each host, the IPs that were dialed. Used for IP selection for later attempts. DialedIPs map[string][]net.IP // For each host, the IPs that were dialed. Used for IP selection for later attempts.
@ -174,9 +203,20 @@ func Init() error {
} }
return fmt.Errorf("open queue database: %s", err) return fmt.Errorf("open queue database: %s", err)
} }
metricHoldUpdate()
return nil return nil
} }
// When we update the gauge, we just get the full current value, not try to account
// for adds/removes.
func metricHoldUpdate() {
count, err := bstore.QueryDB[Msg](context.Background(), DB).FilterNonzero(Msg{Hold: true}).Count()
if err != nil {
mlog.New("queue", nil).Errorx("querying number of queued messages that are on hold", err)
}
metricHold.Set(float64(count))
}
// Shutdown closes the queue database. The delivery process isn't stopped. For tests only. // Shutdown closes the queue database. The delivery process isn't stopped. For tests only.
func Shutdown() { func Shutdown() {
err := DB.Close() err := DB.Close()
@ -186,10 +226,85 @@ func Shutdown() {
DB = nil DB = nil
} }
// Filter filters messages to list or operate on. Used by admin web interface
// and cli.
//
// Only non-empty/non-zero values are applied to the filter. Leaving all fields
// empty/zero matches all messages.
type Filter struct {
IDs []int64
Account string
From string
To string
Hold *bool
Submitted string // Whether submitted before/after a time relative to now. ">$duration" or "<$duration", also with "now" for duration.
NextAttempt string // ">$duration" or "<$duration", also with "now" for duration.
Transport *string
}
func (f Filter) apply(q *bstore.Query[Msg]) error {
if len(f.IDs) > 0 {
q.FilterIDs(f.IDs)
}
applyTime := func(field string, s string) error {
orig := s
var before bool
if strings.HasPrefix(s, "<") {
before = true
} else if !strings.HasPrefix(s, ">") {
return fmt.Errorf(`must start with "<" for before or ">" for after a duration`)
}
s = s[1:]
var t time.Time
if s == "now" {
t = time.Now()
} else if d, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("parsing duration %q: %v", orig, err)
} else {
t = time.Now().Add(d)
}
if before {
q.FilterLess(field, t)
} else {
q.FilterGreater(field, t)
}
return nil
}
if f.Hold != nil {
q.FilterEqual("Hold", *f.Hold)
}
if f.Submitted != "" {
if err := applyTime("Queued", f.Submitted); err != nil {
return fmt.Errorf("applying filter for submitted: %v", err)
}
}
if f.NextAttempt != "" {
if err := applyTime("NextAttempt", f.NextAttempt); err != nil {
return fmt.Errorf("applying filter for next attempt: %v", err)
}
}
if f.Account != "" {
q.FilterNonzero(Msg{SenderAccount: f.Account})
}
if f.Transport != nil {
q.FilterEqual("Transport", *f.Transport)
}
if f.From != "" || f.To != "" {
q.FilterFn(func(m Msg) bool {
return f.From != "" && strings.Contains(m.Sender().XString(true), f.From) || f.To != "" && strings.Contains(m.Recipient().XString(true), f.To)
})
}
return nil
}
// List returns all messages in the delivery queue. // List returns all messages in the delivery queue.
// Ordered by earliest delivery attempt first. // Ordered by earliest delivery attempt first.
func List(ctx context.Context) ([]Msg, error) { func List(ctx context.Context, f Filter) ([]Msg, error) {
qmsgs, err := bstore.QueryDB[Msg](ctx, DB).List() q := bstore.QueryDB[Msg](ctx, DB)
if err := f.apply(q); err != nil {
return nil, err
}
qmsgs, err := q.List()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -216,6 +331,59 @@ func Count(ctx context.Context) (int, error) {
return bstore.QueryDB[Msg](ctx, DB).Count() return bstore.QueryDB[Msg](ctx, DB).Count()
} }
// HoldRuleList returns all hold rules.
func HoldRuleList(ctx context.Context) ([]HoldRule, error) {
return bstore.QueryDB[HoldRule](ctx, DB).List()
}
// HoldRuleAdd adds a new hold rule causing newly submitted messages to be marked
// as "on hold", and existing matching messages too.
func HoldRuleAdd(ctx context.Context, log mlog.Log, hr HoldRule) (HoldRule, error) {
err := DB.Write(ctx, func(tx *bstore.Tx) error {
hr.ID = 0
hr.SenderDomainStr = hr.SenderDomain.Name()
hr.RecipientDomainStr = hr.RecipientDomain.Name()
if err := tx.Insert(&hr); err != nil {
return err
}
log.Info("adding hold rule", slog.Any("holdrule", hr))
q := bstore.QueryTx[Msg](tx)
if !hr.All() {
q.FilterNonzero(Msg{
SenderAccount: hr.Account,
SenderDomainStr: hr.SenderDomainStr,
RecipientDomainStr: hr.RecipientDomainStr,
})
}
n, err := q.UpdateField("Hold", true)
if err != nil {
return fmt.Errorf("marking existing matching messages in queue on hold: %v", err)
}
log.Info("marked messages in queue as on hold", slog.Int("messages", n))
return nil
})
if err != nil {
return HoldRule{}, err
}
queuekick()
metricHoldUpdate()
return hr, nil
}
// HoldRuleRemove removes a hold rule. The Hold field of existing messages are not
// changed.
func HoldRuleRemove(ctx context.Context, log mlog.Log, holdRuleID int64) error {
return DB.Write(ctx, func(tx *bstore.Tx) error {
hr := HoldRule{ID: holdRuleID}
if err := tx.Get(&hr); err != nil {
return err
}
log.Info("removing hold rule", slog.Any("holdrule", hr))
return tx.Delete(HoldRule{ID: holdRuleID})
})
}
// MakeMsg is a convenience function that sets the commonly used fields for a Msg. // MakeMsg is a convenience function that sets the commonly used fields for a Msg.
func MakeMsg(sender, recipient smtp.Path, has8bit, smtputf8 bool, size int64, messageID string, prefix []byte, requireTLS *bool, next time.Time) Msg { func MakeMsg(sender, recipient smtp.Path, has8bit, smtputf8 bool, size int64, messageID string, prefix []byte, requireTLS *bool, next time.Time) Msg {
return Msg{ return Msg{
@ -223,7 +391,6 @@ func MakeMsg(sender, recipient smtp.Path, has8bit, smtputf8 bool, size int64, me
SenderDomain: sender.IPDomain, SenderDomain: sender.IPDomain,
RecipientLocalpart: recipient.Localpart, RecipientLocalpart: recipient.Localpart,
RecipientDomain: recipient.IPDomain, RecipientDomain: recipient.IPDomain,
RecipientDomainStr: formatIPDomain(recipient.IPDomain),
Has8bit: has8bit, Has8bit: has8bit,
SMTPUTF8: smtputf8, SMTPUTF8: smtputf8,
Size: size, Size: size,
@ -242,25 +409,20 @@ func MakeMsg(sender, recipient smtp.Path, has8bit, smtputf8 bool, size int64, me
// //
// ID of the messagse must be 0 and will be set after inserting in the queue. // ID of the messagse must be 0 and will be set after inserting in the queue.
// //
// Add sets derived fields like RecipientDomainStr, and fields related to queueing, // Add sets derived fields like SenderDomainStr and RecipientDomainStr, and fields
// such as Queued, NextAttempt, LastAttempt, LastError. // related to queueing, such as Queued, NextAttempt, LastAttempt, LastError.
func Add(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...Msg) error { func Add(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...Msg) error {
if len(qml) == 0 { if len(qml) == 0 {
return fmt.Errorf("must queue at least one message") return fmt.Errorf("must queue at least one message")
} }
for _, qm := range qml { for i, qm := range qml {
if qm.ID != 0 { if qm.ID != 0 {
return fmt.Errorf("id of queued messages must be 0") return fmt.Errorf("id of queued messages must be 0")
} }
if qm.RecipientDomainStr == "" {
return fmt.Errorf("recipient domain cannot be empty")
}
// Sanity check, internal consistency. // Sanity check, internal consistency.
rcptDom := formatIPDomain(qm.RecipientDomain) qml[i].SenderDomainStr = formatIPDomain(qm.SenderDomain)
if qm.RecipientDomainStr != rcptDom { qml[i].RecipientDomainStr = formatIPDomain(qm.RecipientDomain)
return fmt.Errorf("mismatch between recipient domain and string form of domain")
}
} }
if Localserve { if Localserve {
@ -307,12 +469,24 @@ func Add(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.Fi
} }
}() }()
// Mark messages Hold if they match a hold rule.
holdRules, err := bstore.QueryTx[HoldRule](tx).List()
if err != nil {
return fmt.Errorf("getting queue hold rules")
}
// Insert messages into queue. If there are multiple messages, they all get a // Insert messages into queue. If there are multiple messages, they all get a
// non-zero BaseID that is the Msg.ID of the first message inserted. // non-zero BaseID that is the Msg.ID of the first message inserted.
var baseID int64 var baseID int64
for i := range qml { for i := range qml {
qml[i].SenderAccount = senderAccount qml[i].SenderAccount = senderAccount
qml[i].BaseID = baseID qml[i].BaseID = baseID
for _, hr := range holdRules {
if hr.matches(qml[i]) {
qml[i].Hold = true
break
}
}
if err := tx.Insert(&qml[i]); err != nil { if err := tx.Insert(&qml[i]); err != nil {
return err return err
} }
@ -351,7 +525,15 @@ func Add(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.Fi
tx = nil tx = nil
paths = nil paths = nil
for _, m := range qml {
if m.Hold {
metricHoldUpdate()
break
}
}
queuekick() queuekick()
return nil return nil
} }
@ -374,36 +556,43 @@ func queuekick() {
} }
} }
// Kick sets the NextAttempt for messages matching all filter parameters (ID, // NextAttemptAdd adds a duration to the NextAttempt for all matching messages, and
// toDomain, recipient) that are nonzero, and kicks the queue, attempting delivery // kicks the queue.
// of those messages. If all parameters are zero, all messages are kicked. If func NextAttemptAdd(ctx context.Context, f Filter, d time.Duration) (affected int, err error) {
// transport is set, the delivery attempts for the matching messages will use the err = DB.Write(ctx, func(tx *bstore.Tx) error {
// transport. An empty string is the default transport, i.e. direct delivery.
// Returns number of messages queued for immediate delivery.
func Kick(ctx context.Context, ID int64, toDomain, recipient string, transport *string) (int, error) {
q := bstore.QueryDB[Msg](ctx, DB) q := bstore.QueryDB[Msg](ctx, DB)
if ID > 0 { if err := f.apply(q); err != nil {
q.FilterID(ID) return err
} }
if toDomain != "" { var msgs []Msg
q.FilterEqual("RecipientDomainStr", toDomain) msgs, err := q.List()
if err != nil {
return fmt.Errorf("listing matching messages: %v", err)
} }
if recipient != "" { for _, m := range msgs {
q.FilterFn(func(qm Msg) bool { m.NextAttempt = m.NextAttempt.Add(d)
return qm.Recipient().XString(true) == recipient if err := tx.Update(&m); err != nil {
return err
}
}
affected = len(msgs)
return nil
}) })
if err != nil {
return 0, err
} }
up := map[string]any{"NextAttempt": time.Now()} queuekick()
if transport != nil { return affected, nil
if *transport != "" { }
_, ok := mox.Conf.Static.Transports[*transport]
if !ok { // NextAttemptSet sets NextAttempt for all matching messages to a new time, and
return 0, fmt.Errorf("unknown transport %q", *transport) // kicks the queue.
func NextAttemptSet(ctx context.Context, f Filter, t time.Time) (affected int, err error) {
q := bstore.QueryDB[Msg](ctx, DB)
if err := f.apply(q); err != nil {
return 0, err
} }
} n, err := q.UpdateNonzero(Msg{NextAttempt: t})
up["Transport"] = *transport
}
n, err := q.UpdateFields(up)
if err != nil { if err != nil {
return 0, fmt.Errorf("selecting and updating messages in queue: %v", err) return 0, fmt.Errorf("selecting and updating messages in queue: %v", err)
} }
@ -411,21 +600,74 @@ func Kick(ctx context.Context, ID int64, toDomain, recipient string, transport *
return n, nil return n, nil
} }
// Drop removes messages from the queue that match all nonzero parameters. // HoldSet sets Hold for all matching messages and kicks the queue.
// If all parameters are zero, all messages are removed. func HoldSet(ctx context.Context, f Filter, hold bool) (affected int, err error) {
// Returns number of messages removed.
func Drop(ctx context.Context, log mlog.Log, ID int64, toDomain string, recipient string) (int, error) {
q := bstore.QueryDB[Msg](ctx, DB) q := bstore.QueryDB[Msg](ctx, DB)
if ID > 0 { if err := f.apply(q); err != nil {
q.FilterID(ID) return 0, err
} }
if toDomain != "" { n, err := q.UpdateFields(map[string]any{"Hold": hold})
q.FilterEqual("RecipientDomainStr", toDomain) if err != nil {
return 0, fmt.Errorf("selecting and updating messages in queue: %v", err)
} }
if recipient != "" { queuekick()
q.FilterFn(func(qm Msg) bool { metricHoldUpdate()
return qm.Recipient().XString(true) == recipient return n, nil
}
// TransportSet changes the transport to use for the matching messages.
func TransportSet(ctx context.Context, f Filter, transport string) (affected int, err error) {
q := bstore.QueryDB[Msg](ctx, DB)
if err := f.apply(q); err != nil {
return 0, err
}
n, err := q.UpdateFields(map[string]any{"Transport": transport})
if err != nil {
return 0, fmt.Errorf("selecting and updating messages in queue: %v", err)
}
queuekick()
return n, nil
}
// Fail marks matching messages as failed for delivery and delivers DSNs to the sender.
func Fail(ctx context.Context, log mlog.Log, f Filter) (affected int, err error) {
err = DB.Write(ctx, func(tx *bstore.Tx) error {
q := bstore.QueryTx[Msg](tx)
if err := f.apply(q); err != nil {
return err
}
var msgs []Msg
q.Gather(&msgs)
n, err := q.Delete()
if err != nil {
return fmt.Errorf("selecting and deleting messages from queue: %v", err)
}
var remoteMTA dsn.NameIP
for _, m := range msgs {
if m.LastAttempt == nil {
now := time.Now()
m.LastAttempt = &now
}
deliverDSNFailure(ctx, log, m, remoteMTA, "", "delivery canceled by admin", nil)
}
affected = n
return nil
}) })
if err != nil {
return 0, fmt.Errorf("selecting and updating messages in queue: %v", err)
}
queuekick()
metricHoldUpdate()
return affected, nil
}
// Drop removes matching messages from the queue.
// Returns number of messages removed.
func Drop(ctx context.Context, log mlog.Log, f Filter) (affected int, err error) {
q := bstore.QueryDB[Msg](ctx, DB)
if err := f.apply(q); err != nil {
return 0, err
} }
var msgs []Msg var msgs []Msg
q.Gather(&msgs) q.Gather(&msgs)
@ -439,19 +681,20 @@ func Drop(ctx context.Context, log mlog.Log, ID int64, toDomain string, recipien
log.Errorx("removing queue message from file system", err, slog.Int64("queuemsgid", m.ID), slog.String("path", p)) log.Errorx("removing queue message from file system", err, slog.Int64("queuemsgid", m.ID), slog.String("path", p))
} }
} }
queuekick()
metricHoldUpdate()
return n, nil return n, nil
} }
// SaveRequireTLS updates the RequireTLS field of the message with id. // RequireTLSSet updates the RequireTLS field of matching messages.
func SaveRequireTLS(ctx context.Context, id int64, requireTLS *bool) error { func RequireTLSSet(ctx context.Context, f Filter, requireTLS *bool) (affected int, err error) {
return DB.Write(ctx, func(tx *bstore.Tx) error { q := bstore.QueryDB[Msg](ctx, DB)
m := Msg{ID: id} if err := f.apply(q); err != nil {
if err := tx.Get(&m); err != nil { return 0, err
return fmt.Errorf("get message: %w", err)
} }
m.RequireTLS = requireTLS n, err := q.UpdateFields(map[string]any{"RequireTLS": requireTLS})
return tx.Update(&m) queuekick()
}) return n, err
} }
type ReadReaderAtCloser interface { type ReadReaderAtCloser interface {
@ -522,6 +765,7 @@ func nextWork(ctx context.Context, log mlog.Log, busyDomains map[string]struct{}
} }
q.FilterNotEqual("RecipientDomainStr", doms...) q.FilterNotEqual("RecipientDomainStr", doms...)
} }
q.FilterEqual("Hold", false)
q.SortAsc("NextAttempt") q.SortAsc("NextAttempt")
q.Limit(1) q.Limit(1)
qm, err := q.Get() qm, err := q.Get()
@ -537,6 +781,7 @@ func nextWork(ctx context.Context, log mlog.Log, busyDomains map[string]struct{}
func launchWork(log mlog.Log, resolver dns.Resolver, busyDomains map[string]struct{}) int { func launchWork(log mlog.Log, resolver dns.Resolver, busyDomains map[string]struct{}) int {
q := bstore.QueryDB[Msg](mox.Shutdown, DB) q := bstore.QueryDB[Msg](mox.Shutdown, DB)
q.FilterLessEqual("NextAttempt", time.Now()) q.FilterLessEqual("NextAttempt", time.Now())
q.FilterEqual("Hold", false)
q.SortAsc("NextAttempt") q.SortAsc("NextAttempt")
q.Limit(maxConcurrentDeliveries) q.Limit(maxConcurrentDeliveries)
if len(busyDomains) > 0 { if len(busyDomains) > 0 {
@ -679,6 +924,7 @@ func deliver(log mlog.Log, resolver dns.Resolver, m Msg) {
q.FilterNonzero(Msg{BaseID: m.BaseID, RecipientDomainStr: m.RecipientDomainStr, Attempts: m.Attempts - 1}) q.FilterNonzero(Msg{BaseID: m.BaseID, RecipientDomainStr: m.RecipientDomainStr, Attempts: m.Attempts - 1})
q.FilterNotEqual("ID", m.ID) q.FilterNotEqual("ID", m.ID)
q.FilterLessEqual("NextAttempt", origNextAttempt) q.FilterLessEqual("NextAttempt", origNextAttempt)
q.FilterEqual("Hold", false)
err := q.ForEach(func(xm Msg) error { err := q.ForEach(func(xm Msg) error {
mrtls := m.RequireTLS != nil mrtls := m.RequireTLS != nil
xmrtls := xm.RequireTLS != nil xmrtls := xm.RequireTLS != nil

View file

@ -104,7 +104,20 @@ func TestQueue(t *testing.T) {
err := Init() err := Init()
tcheck(t, err, "queue init") tcheck(t, err, "queue init")
msgs, err := List(ctxbg) idfilter := func(msgID int64) Filter {
return Filter{IDs: []int64{msgID}}
}
kick := func(expn int, id int64) {
t.Helper()
n, err := NextAttemptSet(ctxbg, idfilter(id), time.Now())
tcheck(t, err, "kick queue")
if n != expn {
t.Fatalf("kick changed %d messages, expected %d", n, expn)
}
}
msgs, err := List(ctxbg, Filter{})
tcheck(t, err, "listing messages in queue") tcheck(t, err, "listing messages in queue")
if len(msgs) != 0 { if len(msgs) != 0 {
t.Fatalf("got %d messages in queue, expected 0", len(msgs)) t.Fatalf("got %d messages in queue, expected 0", len(msgs))
@ -125,16 +138,26 @@ func TestQueue(t *testing.T) {
err = Add(ctxbg, pkglog, "mjl", mf, qm) err = Add(ctxbg, pkglog, "mjl", mf, qm)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
msgs, err = List(ctxbg) qm = MakeMsg(path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, nil, time.Now())
err = Add(ctxbg, pkglog, "mjl", mf, qm)
tcheck(t, err, "add message to queue for delivery")
msgs, err = List(ctxbg, Filter{})
tcheck(t, err, "listing queue") tcheck(t, err, "listing queue")
if len(msgs) != 2 { if len(msgs) != 3 {
t.Fatalf("got msgs %v, expected 1", msgs) t.Fatalf("got msgs %v, expected 1", msgs)
} }
yes := true
n, err := RequireTLSSet(ctxbg, Filter{IDs: []int64{msgs[2].ID}}, &yes)
tcheck(t, err, "requiretlsset")
tcompare(t, n, 1)
msg := msgs[0] msg := msgs[0]
if msg.Attempts != 0 { if msg.Attempts != 0 {
t.Fatalf("msg attempts %d, expected 0", msg.Attempts) t.Fatalf("msg attempts %d, expected 0", msg.Attempts)
} }
n, err := Drop(ctxbg, pkglog, msgs[1].ID, "", "") n, err = Drop(ctxbg, pkglog, Filter{IDs: []int64{msgs[1].ID}})
tcheck(t, err, "drop") tcheck(t, err, "drop")
if n != 1 { if n != 1 {
t.Fatalf("dropped %d, expected 1", n) t.Fatalf("dropped %d, expected 1", n)
@ -143,6 +166,48 @@ func TestQueue(t *testing.T) {
t.Fatalf("dropped message not removed from file system") t.Fatalf("dropped message not removed from file system")
} }
// Fail a message, check the account has a message afterwards, the DSN.
n, err = bstore.QueryDB[store.Message](ctxbg, acc.DB).Count()
tcheck(t, err, "count messages in account")
tcompare(t, n, 0)
n, err = Fail(ctxbg, pkglog, Filter{IDs: []int64{msgs[2].ID}})
tcheck(t, err, "fail")
if n != 1 {
t.Fatalf("failed %d, expected 1", n)
}
n, err = bstore.QueryDB[store.Message](ctxbg, acc.DB).Count()
tcheck(t, err, "count messages in account")
tcompare(t, n, 1)
// Check filter through various List calls. Other code uses the same filtering function.
filter := func(f Filter, expn int) {
t.Helper()
l, err := List(ctxbg, f)
tcheck(t, err, "list messages")
tcompare(t, len(l), expn)
}
filter(Filter{}, 1)
filter(Filter{Account: "mjl"}, 1)
filter(Filter{Account: "bogus"}, 0)
filter(Filter{IDs: []int64{msgs[0].ID}}, 1)
filter(Filter{IDs: []int64{msgs[2].ID}}, 0) // Removed.
filter(Filter{IDs: []int64{msgs[2].ID + 1}}, 0) // Never existed.
filter(Filter{From: "mjl@"}, 1)
filter(Filter{From: "bogus@"}, 0)
filter(Filter{To: "mjl@"}, 1)
filter(Filter{To: "bogus@"}, 0)
filter(Filter{Hold: &yes}, 0)
no := false
filter(Filter{Hold: &no}, 1)
filter(Filter{Submitted: "<now"}, 1)
filter(Filter{Submitted: ">now"}, 0)
filter(Filter{NextAttempt: "<1m"}, 1)
filter(Filter{NextAttempt: ">1m"}, 0)
var empty string
bogus := "bogus"
filter(Filter{Transport: &empty}, 1)
filter(Filter{Transport: &bogus}, 0)
next := nextWork(ctxbg, pkglog, nil) next := nextWork(ctxbg, pkglog, nil)
if next > 0 { if next > 0 {
t.Fatalf("nextWork in %s, should be now", next) t.Fatalf("nextWork in %s, should be now", next)
@ -217,12 +282,13 @@ func TestQueue(t *testing.T) {
t.Fatalf("message mismatch, got %q, expected %q", string(msgbuf), testmsg) t.Fatalf("message mismatch, got %q, expected %q", string(msgbuf), testmsg)
} }
n, err = Kick(ctxbg, msg.ID+1, "", "", nil) // Reduce by more than first attempt interval of 7.5 minutes.
n, err = NextAttemptAdd(ctxbg, idfilter(msg.ID+1), -10*time.Minute)
tcheck(t, err, "kick") tcheck(t, err, "kick")
if n != 0 { if n != 0 {
t.Fatalf("kick %d, expected 0", n) t.Fatalf("kick %d, expected 0", n)
} }
n, err = Kick(ctxbg, msg.ID, "", "", nil) n, err = NextAttemptAdd(ctxbg, idfilter(msg.ID), -10*time.Minute)
tcheck(t, err, "kick") tcheck(t, err, "kick")
if n != 1 { if n != 1 {
t.Fatalf("kicked %d, expected 1", n) t.Fatalf("kicked %d, expected 1", n)
@ -485,7 +551,7 @@ func TestQueue(t *testing.T) {
case <-smtpdone: case <-smtpdone:
i := 0 i := 0
for { for {
xmsgs, err := List(ctxbg) xmsgs, err := List(ctxbg, Filter{})
tcheck(t, err, "list queue") tcheck(t, err, "list queue")
if len(xmsgs) == 0 { if len(xmsgs) == 0 {
ninbox, err := bstore.QueryDB[store.Message](ctxbg, acc.DB).FilterNonzero(store.Message{MailboxID: inbox.ID}).Count() ninbox, err := bstore.QueryDB[store.Message](ctxbg, acc.DB).FilterNonzero(store.Message{MailboxID: inbox.ID}).Count()
@ -595,10 +661,10 @@ func TestQueue(t *testing.T) {
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
transportSubmitTLS := "submittls" transportSubmitTLS := "submittls"
n, err = Kick(ctxbg, qml[0].ID, "", "", &transportSubmitTLS) n, err = TransportSet(ctxbg, Filter{IDs: []int64{qml[0].ID}}, transportSubmitTLS)
tcheck(t, err, "kick queue") tcheck(t, err, "set transport")
if n != 1 { if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n) t.Fatalf("TransportSet changed %d messages, expected 1", n)
} }
// Make fake cert, and make it trusted. // Make fake cert, and make it trusted.
cert := fakeCert(t, "submission.example", false) cert := fakeCert(t, "submission.example", false)
@ -643,12 +709,12 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<socks@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<socks@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
transportSocks := "socks" n, err = TransportSet(ctxbg, idfilter(qml[0].ID), "socks")
n, err = Kick(ctxbg, qml[0].ID, "", "", &transportSocks) tcheck(t, err, "TransportSet")
tcheck(t, err, "kick queue")
if n != 1 { if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n) t.Fatalf("TransportSet changed %d messages, expected 1", n)
} }
kick(1, qml[0].ID)
wasNetDialer = testDeliver(fakeSMTPServer) wasNetDialer = testDeliver(fakeSMTPServer)
if wasNetDialer { if wasNetDialer {
t.Fatalf("expected non-net.Dialer as dialer") // SOCKS5 dialer is a private type, we cannot check for it. t.Fatalf("expected non-net.Dialer as dialer") // SOCKS5 dialer is a private type, we cannot check for it.
@ -659,11 +725,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted))) checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted)))
checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailHost))) checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailHost)))
@ -673,11 +735,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<badtls@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<badtls@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(makeBadFakeSMTPSTARTTLSServer(true)) testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
checkTLSResults(t, "mox.example", "mox.example", false, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdBadProtocol))) checkTLSResults(t, "mox.example", "mox.example", false, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdBadProtocol)))
checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailHost, fdBadProtocol))) checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailHost, fdBadProtocol)))
@ -693,11 +751,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<dane@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<dane@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted))) checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted)))
checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.Result{Policy: tlsrpt.TLSAPolicy(resolver.TLSA["_25._tcp.mail.mox.example."], mailHost), FailureDetails: []tlsrpt.FailureDetails{}})) checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.Result{Policy: tlsrpt.TLSAPolicy(resolver.TLSA["_25._tcp.mail.mox.example."], mailHost), FailureDetails: []tlsrpt.FailureDetails{}}))
@ -710,15 +764,10 @@ func TestQueue(t *testing.T) {
tcompare(t, rdt.RequireTLS, true) tcompare(t, rdt.RequireTLS, true)
// Add message to be delivered with verified TLS and REQUIRETLS. // Add message to be delivered with verified TLS and REQUIRETLS.
yes := true
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, &yes, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, &yes, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
// Check that message is delivered with all unusable DANE records. // Check that message is delivered with all unusable DANE records.
@ -731,11 +780,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<daneunusable@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<daneunusable@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted))) checkTLSResults(t, "mox.example", "mox.example", false, addCounts(1, 0, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdNotTrusted)))
checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.Result{Policy: tlsrpt.TLSAPolicy([]adns.TLSA{}, mailHost), FailureDetails: []tlsrpt.FailureDetails{fdTLSAUnusable}})) checkTLSResults(t, "mail.mox.example", "mox.example", true, addCounts(1, 0, tlsrpt.Result{Policy: tlsrpt.TLSAPolicy([]adns.TLSA{}, mailHost), FailureDetails: []tlsrpt.FailureDetails{fdTLSAUnusable}}))
@ -752,11 +797,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<daneinsecure@localhost>", nil, nil, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<daneinsecure@localhost>", nil, nil, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(makeBadFakeSMTPSTARTTLSServer(true)) testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
resolver.Inauthentic = nil resolver.Inauthentic = nil
checkTLSResults(t, "mox.example", "mox.example", false, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdBadProtocol))) checkTLSResults(t, "mox.example", "mox.example", false, addCounts(0, 1, tlsrpt.MakeResult(tlsrpt.NoPolicyFound, mailDomain, fdBadProtocol)))
@ -770,37 +811,24 @@ func TestQueue(t *testing.T) {
tcompare(t, rdt.RequireTLS, false) tcompare(t, rdt.RequireTLS, false)
// Check that message is delivered with TLS-Required: No and non-matching DANE record. // Check that message is delivered with TLS-Required: No and non-matching DANE record.
no := false
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednostarttls@localhost>", nil, &no, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednostarttls@localhost>", nil, &no, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
// Check that message is delivered with TLS-Required: No and bad TLS, falling back to plain text. // Check that message is delivered with TLS-Required: No and bad TLS, falling back to plain text.
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednoplaintext@localhost>", nil, &no, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednoplaintext@localhost>", nil, &no, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(makeBadFakeSMTPSTARTTLSServer(true)) testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
// Add message with requiretls that fails immediately due to no REQUIRETLS support in all servers. // Add message with requiretls that fails immediately due to no REQUIRETLS support in all servers.
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequiredunsupported@localhost>", nil, &yes, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequiredunsupported@localhost>", nil, &yes, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDSN(makeBadFakeSMTPSTARTTLSServer(false)) testDSN(makeBadFakeSMTPSTARTTLSServer(false))
// Restore pre-DANE behaviour. // Restore pre-DANE behaviour.
@ -811,11 +839,7 @@ func TestQueue(t *testing.T) {
qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednopolicy@localhost>", nil, &yes, time.Now())} qml = []Msg{MakeMsg(path, path, false, false, int64(len(testmsg)), "<tlsrequirednopolicy@localhost>", nil, &yes, time.Now())}
err = Add(ctxbg, pkglog, "mjl", mf, qml...) err = Add(ctxbg, pkglog, "mjl", mf, qml...)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, qml[0].ID, "", "", nil) kick(1, qml[0].ID)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
// Based on DNS lookups, there won't be any dialing or SMTP connection. // Based on DNS lookups, there won't be any dialing or SMTP connection.
dialed <- struct{}{} dialed <- struct{}{}
testDSN(func(conn net.Conn) { testDSN(func(conn net.Conn) {
@ -827,7 +851,7 @@ func TestQueue(t *testing.T) {
err = Add(ctxbg, pkglog, "mjl", mf, qm) err = Add(ctxbg, pkglog, "mjl", mf, qm)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
msgs, err = List(ctxbg) msgs, err = List(ctxbg, Filter{})
tcheck(t, err, "list queue") tcheck(t, err, "list queue")
if len(msgs) != 1 { if len(msgs) != 1 {
t.Fatalf("queue has %d messages, expected 1", len(msgs)) t.Fatalf("queue has %d messages, expected 1", len(msgs))
@ -1010,6 +1034,14 @@ func TestQueueStart(t *testing.T) {
} }
} }
// HoldRule prevents delivery.
hr, err := HoldRuleAdd(ctxbg, pkglog, HoldRule{})
tcheck(t, err, "add hold rule")
hrl, err := HoldRuleList(ctxbg)
tcheck(t, err, "listing hold rules")
tcompare(t, hrl, []HoldRule{hr})
path := smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mox.example"}}} path := smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mox.example"}}}
mf := prepareFile(t) mf := prepareFile(t)
defer os.Remove(mf.Name()) defer os.Remove(mf.Name())
@ -1017,20 +1049,50 @@ func TestQueueStart(t *testing.T) {
qm := MakeMsg(path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, nil, time.Now()) qm := MakeMsg(path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, nil, time.Now())
err = Add(ctxbg, pkglog, "mjl", mf, qm) err = Add(ctxbg, pkglog, "mjl", mf, qm)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
checkDialed(false) // No delivery attempt yet.
n, err := Count(ctxbg)
tcheck(t, err, "count messages in queue")
tcompare(t, n, 1)
// Take message off hold.
n, err = HoldSet(ctxbg, Filter{}, false)
tcheck(t, err, "taking message off hold")
tcompare(t, n, 1)
checkDialed(true) checkDialed(true)
// Remove hold rule.
err = HoldRuleRemove(ctxbg, pkglog, hr.ID)
tcheck(t, err, "removing hold rule")
// Check it is gone.
hrl, err = HoldRuleList(ctxbg)
tcheck(t, err, "listing hold rules")
tcompare(t, len(hrl), 0)
// Don't change message nextattempt time, but kick queue. Message should not be delivered. // Don't change message nextattempt time, but kick queue. Message should not be delivered.
queuekick() queuekick()
checkDialed(false) checkDialed(false)
// Kick for real, should see another attempt. // Set new next attempt, should see another attempt.
n, err := Kick(ctxbg, 0, "mox.example", "", nil) n, err = NextAttemptSet(ctxbg, Filter{From: "@mox.example"}, time.Now())
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
if n != 1 { if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n) t.Fatalf("kick changed %d messages, expected 1", n)
} }
checkDialed(true) checkDialed(true)
time.Sleep(100 * time.Millisecond) // Racy... we won't get notified when work is done... time.Sleep(100 * time.Millisecond) // Racy... we won't get notified when work is done...
// Submit another, should be delivered immediately without HoldRule.
path = smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mox.example"}}}
mf = prepareFile(t)
defer os.Remove(mf.Name())
defer mf.Close()
qm = MakeMsg(path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, nil, time.Now())
err = Add(ctxbg, pkglog, "mjl", mf, qm)
tcheck(t, err, "add message to queue for delivery")
checkDialed(true) // Immediate.
time.Sleep(100 * time.Millisecond) // Racy... give time to finish.
} }
// Just a cert that appears valid. // Just a cert that appears valid.

View file

@ -1443,7 +1443,7 @@ test email
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
msgs, err := queue.List(ctxbg) msgs, err := queue.List(ctxbg, queue.Filter{})
tcheck(t, err, "listing queue") tcheck(t, err, "listing queue")
n++ n++
tcompare(t, len(msgs), n) tcompare(t, len(msgs), n)
@ -1592,11 +1592,11 @@ test email
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
msgs, err := queue.List(ctxbg) msgs, err := queue.List(ctxbg, queue.Filter{})
tcheck(t, err, "listing queue") tcheck(t, err, "listing queue")
tcompare(t, len(msgs), 1) tcompare(t, len(msgs), 1)
tcompare(t, msgs[0].RequireTLS, expRequireTLS) tcompare(t, msgs[0].RequireTLS, expRequireTLS)
_, err = queue.Drop(ctxbg, pkglog, msgs[0].ID, "", "") _, err = queue.Drop(ctxbg, pkglog, queue.Filter{IDs: []int64{msgs[0].ID}})
tcheck(t, err, "deleting message from queue") tcheck(t, err, "deleting message from queue")
}) })
} }

View file

@ -219,6 +219,7 @@ const [dom, style, attr, prop] = (function () {
method: (s) => _attr('method', s), method: (s) => _attr('method', s),
autocomplete: (s) => _attr('autocomplete', s), autocomplete: (s) => _attr('autocomplete', s),
list: (s) => _attr('list', s), list: (s) => _attr('list', s),
form: (s) => _attr('form', s),
}; };
const style = (x) => { return { _styles: x }; }; const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; }; const prop = (x) => { return { _props: x }; };

View file

@ -1974,13 +1974,6 @@ func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientC
return cc return cc
} }
// QueueList returns the messages currently in the outgoing queue.
func (Admin) QueueList(ctx context.Context) []queue.Msg {
l, err := queue.List(ctx)
xcheckf(ctx, err, "listing messages in queue")
return l
}
// QueueSize returns the number of messages currently in the outgoing queue. // QueueSize returns the number of messages currently in the outgoing queue.
func (Admin) QueueSize(ctx context.Context) int { func (Admin) QueueSize(ctx context.Context) int {
n, err := queue.Count(ctx) n, err := queue.Count(ctx)
@ -1988,31 +1981,96 @@ func (Admin) QueueSize(ctx context.Context) int {
return n return n
} }
// QueueKick initiates delivery of a message from the queue and sets the transport // QueueHoldRuleList lists the hold rules.
// to use for delivery. func (Admin) QueueHoldRuleList(ctx context.Context) []queue.HoldRule {
func (Admin) QueueKick(ctx context.Context, id int64, transport string) { l, err := queue.HoldRuleList(ctx)
n, err := queue.Kick(ctx, id, "", "", &transport) xcheckf(ctx, err, "listing queue hold rules")
if err == nil && n == 0 { return l
err = errors.New("message not found")
}
xcheckf(ctx, err, "kick message in queue")
} }
// QueueDrop removes a message from the queue. // QueueHoldRuleAdd adds a hold rule. Newly submitted and existing messages
func (Admin) QueueDrop(ctx context.Context, id int64) { // matching the hold rule will be marked "on hold".
func (Admin) QueueHoldRuleAdd(ctx context.Context, hr queue.HoldRule) queue.HoldRule {
var err error
hr.SenderDomain, err = dns.ParseDomain(hr.SenderDomainStr)
xcheckuserf(ctx, err, "parsing sender domain %q", hr.SenderDomainStr)
hr.RecipientDomain, err = dns.ParseDomain(hr.RecipientDomainStr)
xcheckuserf(ctx, err, "parsing recipient domain %q", hr.RecipientDomainStr)
log := pkglog.WithContext(ctx) log := pkglog.WithContext(ctx)
n, err := queue.Drop(ctx, log, id, "", "") hr, err = queue.HoldRuleAdd(ctx, log, hr)
if err == nil && n == 0 { xcheckf(ctx, err, "adding queue hold rule")
err = errors.New("message not found") return hr
}
xcheckf(ctx, err, "drop message from queue")
} }
// QueueSaveRequireTLS updates the requiretls field for a message in the queue, // QueueHoldRuleRemove removes a hold rule. The Hold field of messages in
// to be used for the next delivery. // the queue are not changed.
func (Admin) QueueSaveRequireTLS(ctx context.Context, id int64, requireTLS *bool) { func (Admin) QueueHoldRuleRemove(ctx context.Context, holdRuleID int64) {
err := queue.SaveRequireTLS(ctx, id, requireTLS) log := pkglog.WithContext(ctx)
xcheckf(ctx, err, "update requiretls for message in queue") err := queue.HoldRuleRemove(ctx, log, holdRuleID)
xcheckf(ctx, err, "removing queue hold rule")
}
// QueueList returns the messages currently in the outgoing queue.
func (Admin) QueueList(ctx context.Context, filter queue.Filter) []queue.Msg {
l, err := queue.List(ctx, filter)
xcheckf(ctx, err, "listing messages in queue")
return l
}
// QueueNextAttemptSet sets a new time for next delivery attempt of matching
// messages from the queue.
func (Admin) QueueNextAttemptSet(ctx context.Context, filter queue.Filter, minutes int) (affected int) {
n, err := queue.NextAttemptSet(ctx, filter, time.Now().Add(time.Duration(minutes)*time.Minute))
xcheckf(ctx, err, "setting new next delivery attempt time for matching messages in queue")
return n
}
// QueueNextAttemptAdd adds a duration to the time of next delivery attempt of
// matching messages from the queue.
func (Admin) QueueNextAttemptAdd(ctx context.Context, filter queue.Filter, minutes int) (affected int) {
n, err := queue.NextAttemptAdd(ctx, filter, time.Duration(minutes)*time.Minute)
xcheckf(ctx, err, "adding duration to next delivery attempt for matching messages in queue")
return n
}
// QueueHoldSet sets the Hold field of matching messages in the queue.
func (Admin) QueueHoldSet(ctx context.Context, filter queue.Filter, onHold bool) (affected int) {
n, err := queue.HoldSet(ctx, filter, onHold)
xcheckf(ctx, err, "changing onhold for matching messages in queue")
return n
}
// QueueFail fails delivery for matching messages, causing DSNs to be sent.
func (Admin) QueueFail(ctx context.Context, filter queue.Filter) (affected int) {
log := pkglog.WithContext(ctx)
n, err := queue.Fail(ctx, log, filter)
xcheckf(ctx, err, "drop messages from queue")
return n
}
// QueueDrop removes matching messages from the queue.
func (Admin) QueueDrop(ctx context.Context, filter queue.Filter) (affected int) {
log := pkglog.WithContext(ctx)
n, err := queue.Drop(ctx, log, filter)
xcheckf(ctx, err, "drop messages from queue")
return n
}
// QueueRequireTLSSet updates the requiretls field for matching messages in the
// queue, to be used for the next delivery.
func (Admin) QueueRequireTLSSet(ctx context.Context, filter queue.Filter, requireTLS *bool) (affected int) {
n, err := queue.RequireTLSSet(ctx, filter, requireTLS)
xcheckf(ctx, err, "update requiretls for messages in queue")
return n
}
// QueueTransportSet initiates delivery of a message from the queue and sets the transport
// to use for delivery.
func (Admin) QueueTransportSet(ctx context.Context, filter queue.Filter, transport string) (affected int) {
n, err := queue.TransportSet(ctx, filter, transport)
xcheckf(ctx, err, "changing transport for messages in queue")
return n
} }
// LogLevels returns the current log levels. // LogLevels returns the current log levels.

View file

@ -219,6 +219,7 @@ const [dom, style, attr, prop] = (function () {
method: (s) => _attr('method', s), method: (s) => _attr('method', s),
autocomplete: (s) => _attr('autocomplete', s), autocomplete: (s) => _attr('autocomplete', s),
list: (s) => _attr('list', s), list: (s) => _attr('list', s),
form: (s) => _attr('form', s),
}; };
const style = (x) => { return { _styles: x }; }; const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; }; const prop = (x) => { return { _props: x }; };
@ -335,7 +336,7 @@ var api;
SPFResult["SPFTemperror"] = "temperror"; SPFResult["SPFTemperror"] = "temperror";
SPFResult["SPFPermerror"] = "permerror"; SPFResult["SPFPermerror"] = "permerror";
})(SPFResult = api.SPFResult || (api.SPFResult = {})); })(SPFResult = api.SPFResult || (api.SPFResult = {}));
api.structTypes = { "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "DANECheckResult": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "Reverse": true, "Row": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; api.structTypes = { "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "DANECheckResult": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "Reverse": true, "Row": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
api.stringsTypes = { "Align": true, "Alignment": true, "CSRFToken": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true }; api.stringsTypes = { "Align": true, "Alignment": true, "CSRFToken": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true };
api.intsTypes = {}; api.intsTypes = {};
api.types = { api.types = {
@ -395,7 +396,9 @@ var api;
"Reverse": { "Name": "Reverse", "Docs": "", "Fields": [{ "Name": "Hostnames", "Docs": "", "Typewords": ["[]", "string"] }] }, "Reverse": { "Name": "Reverse", "Docs": "", "Fields": [{ "Name": "Hostnames", "Docs": "", "Typewords": ["[]", "string"] }] },
"ClientConfigs": { "Name": "ClientConfigs", "Docs": "", "Fields": [{ "Name": "Entries", "Docs": "", "Typewords": ["[]", "ClientConfigsEntry"] }] }, "ClientConfigs": { "Name": "ClientConfigs", "Docs": "", "Fields": [{ "Name": "Entries", "Docs": "", "Typewords": ["[]", "ClientConfigsEntry"] }] },
"ClientConfigsEntry": { "Name": "ClientConfigsEntry", "Docs": "", "Fields": [{ "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "Listener", "Docs": "", "Typewords": ["string"] }, { "Name": "Note", "Docs": "", "Typewords": ["string"] }] }, "ClientConfigsEntry": { "Name": "ClientConfigsEntry", "Docs": "", "Fields": [{ "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "Listener", "Docs": "", "Typewords": ["string"] }, { "Name": "Note", "Docs": "", "Typewords": ["string"] }] },
"Msg": { "Name": "Msg", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "BaseID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Queued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SenderAccount", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "DialedIPs", "Docs": "", "Typewords": ["{}", "[]", "IP"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastAttempt", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "LastError", "Docs": "", "Typewords": ["string"] }, { "Name": "Has8bit", "Docs": "", "Typewords": ["bool"] }, { "Name": "SMTPUTF8", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsDMARCReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsTLSReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "DSNUTF8", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureReleaseRequest", "Docs": "", "Typewords": ["string"] }] }, "HoldRule": { "Name": "HoldRule", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "SenderDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }] },
"Filter": { "Name": "Filter", "Docs": "", "Fields": [{ "Name": "IDs", "Docs": "", "Typewords": ["[]", "int64"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["string"] }, { "Name": "Hold", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "Submitted", "Docs": "", "Typewords": ["string"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["nullable", "string"] }] },
"Msg": { "Name": "Msg", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "BaseID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Queued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Hold", "Docs": "", "Typewords": ["bool"] }, { "Name": "SenderAccount", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "SenderDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "DialedIPs", "Docs": "", "Typewords": ["{}", "[]", "IP"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastAttempt", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "LastError", "Docs": "", "Typewords": ["string"] }, { "Name": "Has8bit", "Docs": "", "Typewords": ["bool"] }, { "Name": "SMTPUTF8", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsDMARCReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsTLSReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "DSNUTF8", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureReleaseRequest", "Docs": "", "Typewords": ["string"] }] },
"IPDomain": { "Name": "IPDomain", "Docs": "", "Fields": [{ "Name": "IP", "Docs": "", "Typewords": ["IP"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "IPDomain": { "Name": "IPDomain", "Docs": "", "Fields": [{ "Name": "IP", "Docs": "", "Typewords": ["IP"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"WebserverConfig": { "Name": "WebserverConfig", "Docs": "", "Fields": [{ "Name": "WebDNSDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "Domain"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }] }, "WebserverConfig": { "Name": "WebserverConfig", "Docs": "", "Fields": [{ "Name": "WebDNSDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "Domain"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }] },
"WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
@ -485,6 +488,8 @@ var api;
Reverse: (v) => api.parse("Reverse", v), Reverse: (v) => api.parse("Reverse", v),
ClientConfigs: (v) => api.parse("ClientConfigs", v), ClientConfigs: (v) => api.parse("ClientConfigs", v),
ClientConfigsEntry: (v) => api.parse("ClientConfigsEntry", v), ClientConfigsEntry: (v) => api.parse("ClientConfigsEntry", v),
HoldRule: (v) => api.parse("HoldRule", v),
Filter: (v) => api.parse("Filter", v),
Msg: (v) => api.parse("Msg", v), Msg: (v) => api.parse("Msg", v),
IPDomain: (v) => api.parse("IPDomain", v), IPDomain: (v) => api.parse("IPDomain", v),
WebserverConfig: (v) => api.parse("WebserverConfig", v), WebserverConfig: (v) => api.parse("WebserverConfig", v),
@ -812,14 +817,6 @@ var api;
const params = [domain]; const params = [domain];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
// QueueList returns the messages currently in the outgoing queue.
async QueueList() {
const fn = "QueueList";
const paramTypes = [];
const returnTypes = [["[]", "Msg"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueSize returns the number of messages currently in the outgoing queue. // QueueSize returns the number of messages currently in the outgoing queue.
async QueueSize() { async QueueSize() {
const fn = "QueueSize"; const fn = "QueueSize";
@ -828,30 +825,98 @@ var api;
const params = []; const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
// QueueKick initiates delivery of a message from the queue and sets the transport // QueueHoldRuleList lists the hold rules.
// to use for delivery. async QueueHoldRuleList() {
async QueueKick(id, transport) { const fn = "QueueHoldRuleList";
const fn = "QueueKick"; const paramTypes = [];
const paramTypes = [["int64"], ["string"]]; const returnTypes = [["[]", "HoldRule"]];
const returnTypes = []; const params = [];
const params = [id, transport];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
// QueueDrop removes a message from the queue. // QueueHoldRuleAdd adds a hold rule. Newly submitted and existing messages
async QueueDrop(id) { // matching the hold rule will be marked "on hold".
const fn = "QueueDrop"; async QueueHoldRuleAdd(hr) {
const fn = "QueueHoldRuleAdd";
const paramTypes = [["HoldRule"]];
const returnTypes = [["HoldRule"]];
const params = [hr];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueHoldRuleRemove removes a hold rule. The Hold field of messages in
// the queue are not changed.
async QueueHoldRuleRemove(holdRuleID) {
const fn = "QueueHoldRuleRemove";
const paramTypes = [["int64"]]; const paramTypes = [["int64"]];
const returnTypes = []; const returnTypes = [];
const params = [id]; const params = [holdRuleID];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
// QueueSaveRequireTLS updates the requiretls field for a message in the queue, // QueueList returns the messages currently in the outgoing queue.
// to be used for the next delivery. async QueueList(filter) {
async QueueSaveRequireTLS(id, requireTLS) { const fn = "QueueList";
const fn = "QueueSaveRequireTLS"; const paramTypes = [["Filter"]];
const paramTypes = [["int64"], ["nullable", "bool"]]; const returnTypes = [["[]", "Msg"]];
const returnTypes = []; const params = [filter];
const params = [id, requireTLS]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueNextAttemptSet sets a new time for next delivery attempt of matching
// messages from the queue.
async QueueNextAttemptSet(filter, minutes) {
const fn = "QueueNextAttemptSet";
const paramTypes = [["Filter"], ["int32"]];
const returnTypes = [["int32"]];
const params = [filter, minutes];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueNextAttemptAdd adds a duration to the time of next delivery attempt of
// matching messages from the queue.
async QueueNextAttemptAdd(filter, minutes) {
const fn = "QueueNextAttemptAdd";
const paramTypes = [["Filter"], ["int32"]];
const returnTypes = [["int32"]];
const params = [filter, minutes];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueHoldSet sets the Hold field of matching messages in the queue.
async QueueHoldSet(filter, onHold) {
const fn = "QueueHoldSet";
const paramTypes = [["Filter"], ["bool"]];
const returnTypes = [["int32"]];
const params = [filter, onHold];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueFail fails delivery for matching messages, causing DSNs to be sent.
async QueueFail(filter) {
const fn = "QueueFail";
const paramTypes = [["Filter"]];
const returnTypes = [["int32"]];
const params = [filter];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueDrop removes matching messages from the queue.
async QueueDrop(filter) {
const fn = "QueueDrop";
const paramTypes = [["Filter"]];
const returnTypes = [["int32"]];
const params = [filter];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueRequireTLSSet updates the requiretls field for matching messages in the
// queue, to be used for the next delivery.
async QueueRequireTLSSet(filter, requireTLS) {
const fn = "QueueRequireTLSSet";
const paramTypes = [["Filter"], ["nullable", "bool"]];
const returnTypes = [["int32"]];
const params = [filter, requireTLS];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueTransportSet initiates delivery of a message from the queue and sets the transport
// to use for delivery.
async QueueTransportSet(filter, transport) {
const fn = "QueueTransportSet";
const paramTypes = [["Filter"], ["string"]];
const returnTypes = [["int32"]];
const params = [filter, transport];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
// LogLevels returns the current log levels. // LogLevels returns the current log levels.
@ -2453,36 +2518,187 @@ const dnsbl = async () => {
}, fieldset = dom.fieldset(dom.div('One per line'), dom.div(style({ marginBottom: '.5ex' }), monitorTextarea = dom.textarea(style({ width: '20rem' }), attr.rows('' + Math.max(5, 1 + (monitorZones || []).length)), new String((monitorZones || []).map(zone => domainName(zone)).join('\n'))), dom.div('Examples: sbl.spamhaus.org or bl.spamcop.net')), dom.div(dom.submitbutton('Save'))))); }, fieldset = dom.fieldset(dom.div('One per line'), dom.div(style({ marginBottom: '.5ex' }), monitorTextarea = dom.textarea(style({ width: '20rem' }), attr.rows('' + Math.max(5, 1 + (monitorZones || []).length)), new String((monitorZones || []).map(zone => domainName(zone)).join('\n'))), dom.div('Examples: sbl.spamhaus.org or bl.spamcop.net')), dom.div(dom.submitbutton('Save')))));
}; };
const queueList = async () => { const queueList = async () => {
const [msgs, transports] = await Promise.all([ let [holdRules, msgs, transports] = await Promise.all([
client.QueueList(), client.QueueHoldRuleList(),
client.QueueList({ IDs: [], Account: '', From: '', To: '', Hold: null, Submitted: '', NextAttempt: '', Transport: null }),
client.Transports(), client.Transports(),
]); ]);
// todo: sorting by address/timestamps/attempts.
// todo: after making changes, don't reload entire page. probably best to fetch messages by id and rerender. also report on which messages weren't affected (e.g. no longer in queue).
// todo: display which transport will be used for a message according to routing rules (in case none is explicitly configured).
// todo: live updates with SSE connections
// todo: keep updating times/age.
const nowSecs = new Date().getTime() / 1000; const nowSecs = new Date().getTime() / 1000;
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Queue'), let holdRuleAccount;
// todo: sorting by address/timestamps/attempts. perhaps filtering. let holdRuleSenderDomain;
dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Submitted'), dom.th('From'), dom.th('To'), dom.th('Size'), dom.th('Attempts'), dom.th('Next attempt'), dom.th('Last attempt'), dom.th('Last error'), dom.th('Require TLS'), dom.th('Transport/Retry'), dom.th('Remove'))), dom.tbody((msgs || []).length === 0 ? dom.tr(dom.td(attr.colspan('12'), 'Currently no messages in the queue.')) : [], (msgs || []).map(m => { let holdRuleRecipientDomain;
let holdRuleSubmit;
let filterForm;
let filterAccount;
let filterFrom;
let filterTo;
let filterSubmitted;
let filterHold;
let filterNextAttempt;
let filterTransport;
let requiretlsFieldset; let requiretlsFieldset;
let requiretls; let requiretls;
let transport; let transport;
return dom.tr(dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderLocalpart + "@" + ipdomainString(m.SenderDomain)), // todo: escaping of localpart // Message ID to checkbox.
let toggles = new Map();
// We operate on what the user has selected, not what the filters would currently
// evaluate to. This function can throw an error, which is why we have awkward
// syntax when calling this as parameter in api client calls below.
const gatherIDs = () => {
const f = {
IDs: Array.from(toggles.entries()).filter(t => t[1].checked).map(t => t[0]),
Account: '',
From: '',
To: '',
Hold: null,
Submitted: '',
NextAttempt: '',
Transport: null,
};
// Don't want to accidentally operate on all messages.
if ((f.IDs || []).length === 0) {
throw new Error('No messages selected.');
}
return f;
};
const tbody = dom.tbody();
const render = () => {
toggles = new Map();
for (const m of (msgs || [])) {
toggles.set(m.ID, dom.input(attr.type('checkbox'), attr.checked('')));
}
dom._kids(tbody, (msgs || []).length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No messages.')) : [], (msgs || []).map(m => {
return dom.tr(dom.td(toggles.get(m.ID)), dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), dom.td(m.SenderLocalpart + "@" + ipdomainString(m.SenderDomain)), // todo: escaping of localpart
dom.td(m.RecipientLocalpart + "@" + ipdomainString(m.RecipientDomain)), // todo: escaping of localpart dom.td(m.RecipientLocalpart + "@" + ipdomainString(m.RecipientDomain)), // todo: escaping of localpart
dom.td(formatSize(m.Size)), dom.td('' + m.Attempts), dom.td(age(new Date(m.NextAttempt), true, nowSecs)), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(m.LastError || '-'), dom.td(dom.form(requiretlsFieldset = dom.fieldset(requiretls = dom.select(attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), dom.option('Default', attr.value('')), dom.option('With RequireTLS', attr.value('yes'), m.RequireTLS === true ? attr.selected('') : []), dom.option('Fallback to insecure', attr.value('no'), m.RequireTLS === false ? attr.selected('') : [])), ' ', dom.submitbutton('Save')), async function submit(e) { dom.td(formatSize(m.Size)), dom.td('' + m.Attempts), dom.td(m.Hold ? 'Hold' : ''), dom.td(age(new Date(m.NextAttempt), true, nowSecs)), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(m.LastError || '-'), dom.td(m.RequireTLS === true ? 'Yes' : (m.RequireTLS === false ? 'No' : 'Default')), dom.td(m.Transport || '(default)'));
}));
};
render();
const buttonNextAttemptSet = (text, minutes) => dom.clickbutton(text, async function click(e) {
// note: awkward client call because gatherIDs() can throw an exception.
const n = await check(e.target, (async () => client.QueueNextAttemptSet(gatherIDs(), minutes))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: reload less
});
const buttonNextAttemptAdd = (text, minutes) => dom.clickbutton(text, async function click(e) {
const n = await check(e.target, (async () => client.QueueNextAttemptAdd(gatherIDs(), minutes))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: reload less
});
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Queue'), dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')), dom.form(attr.id('holdRuleForm'), async function submit(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await check(requiretlsFieldset, client.QueueSaveRequireTLS(m.ID, requiretls.value === '' ? null : requiretls.value === 'yes')); const pr = {
})), dom.td(dom.form(transport = dom.select(attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'), dom.option('(default)', attr.value('')), Object.keys(transports || []).sort().map(t => dom.option(t, m.Transport === t ? attr.checked('') : []))), ' ', dom.submitbutton('Retry now'), async function submit(e) { ID: 0,
Account: holdRuleAccount.value,
SenderDomainStr: holdRuleSenderDomain.value,
RecipientDomainStr: holdRuleRecipientDomain.value,
// Filled in by backend, we provide dummy values.
SenderDomain: { ASCII: '', Unicode: '' },
RecipientDomain: { ASCII: '', Unicode: '' },
};
await check(holdRuleSubmit, client.QueueHoldRuleAdd(pr));
window.location.reload(); // todo: reload less
}), (function () {
// We don't show the full form until asked. Too much visual clutter.
let show = (holdRules || []).length > 0;
const box = dom.div();
const renderHoldRules = () => {
dom._kids(box, !show ?
dom.div('No hold rules. ', dom.clickbutton('Add', function click() {
show = true;
renderHoldRules();
})) : [
dom.p('Newly submitted messages matching a hold rule will be marked as "on hold" and not be delivered until further action by the admin. To create a rule matching all messages, leave all fields empty.'),
dom.table(dom.thead(dom.tr(dom.th('Account'), dom.th('Sender domain'), dom.th('Recipient domain'), dom.th('Action'))), dom.tbody((holdRules || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No hold rules.')) : [], (holdRules || []).map(pr => dom.tr(!pr.Account && !pr.SenderDomainStr && !pr.RecipientDomainStr ?
dom.td(attr.colspan('3'), '(Match all messages)') : [
dom.td(pr.Account),
dom.td(domainString(pr.SenderDomain)),
dom.td(domainString(pr.RecipientDomain)),
], dom.td(dom.clickbutton('Remove', attr.title('Removing a hold rule does not modify the "on hold" status of messages in the queue.'), async function click(e) {
await check(e.target, client.QueueHoldRuleRemove(pr.ID));
window.location.reload(); // todo: reload less
})))), dom.tr(dom.td(holdRuleAccount = dom.input(attr.form('holdRuleForm'))), dom.td(holdRuleSenderDomain = dom.input(attr.form('holdRuleForm'))), dom.td(holdRuleRecipientDomain = dom.input(attr.form('holdRuleForm'))), dom.td(holdRuleSubmit = dom.submitbutton('Add hold rule', attr.form('holdRuleForm'), attr.title('When adding a new hold rule, existing messages in queue matching the new rule will be marked as on hold.'))))))
]);
};
renderHoldRules();
return box;
})(), dom.br(),
// Filtering.
filterForm = dom.form(attr.id('queuefilter'), // Referenced by input elements in table row.
async function submit(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await check(e.target, client.QueueKick(m.ID, transport.value)); const filter = {
IDs: [],
Account: filterAccount.value,
From: filterFrom.value,
To: filterTo.value,
Hold: filterHold.value === 'Yes' ? true : (filterHold.value === 'No' ? false : null),
Submitted: filterSubmitted.value,
NextAttempt: filterNextAttempt.value,
Transport: !filterTransport.value ? null : (filterTransport.value === '(default)' ? '' : filterTransport.value),
};
dom._kids(tbody);
msgs = await check({ disabled: false }, client.QueueList(filter));
render();
}), dom.h2('Messages'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th(), dom.th('ID'), dom.th('Submitted'), dom.th('Account'), dom.th('From'), dom.th('To'), dom.th('Size'), dom.th('Attempts'), dom.th('Hold'), dom.th('Next attempt'), dom.th('Last attempt'), dom.th('Last error'), dom.th('Require TLS'), dom.th('Transport'), dom.th()), dom.tr(dom.td(dom.input(attr.type('checkbox'), attr.checked(''), attr.form('queuefilter'), function change(e) {
const elem = e.target;
for (const [_, toggle] of toggles) {
toggle.checked = elem.checked;
}
})), dom.td(), dom.td(filterSubmitted = dom.input(attr.form('queuefilter'), style({ width: '7em' }), attr.title('Example: "<1h" for filtering messages submitted more than 1 minute ago.'))), dom.td(filterAccount = dom.input(attr.form('queuefilter'))), dom.td(filterFrom = dom.input(attr.form('queuefilter')), attr.title('Example: "@sender.example" to filter by domain of sender.')), dom.td(filterTo = dom.input(attr.form('queuefilter')), attr.title('Example: "@recipient.example" to filter by domain of recipient.')), dom.td(), // todo: add filter by size?
dom.td(), // todo: add filter by attempts?
dom.td(filterHold = dom.select(attr.form('queuefilter'), dom.option('', attr.value('')), dom.option('Yes'), dom.option('No'), function change() {
filterForm.requestSubmit();
})), dom.td(filterNextAttempt = dom.input(attr.form('queuefilter'), style({ width: '7em' }), attr.title('Example: ">1h" for filtering messages to be delivered in more than 1 hour, or "<now" for messages to be delivered as soon as possible.'))), dom.td(), dom.td(), dom.td(), dom.td(filterTransport = dom.select(Object.keys(transports || []).length === 0 ? style({ display: 'none' }) : [], attr.form('queuefilter'), function change() {
filterForm.requestSubmit();
}, dom.option(''), dom.option('(default)'), Object.keys(transports || []).sort().map(t => dom.option(t)))), dom.td(dom.submitbutton('Filter', attr.form('queuefilter')), ' ', dom.clickbutton('Reset', attr.form('queuefilter'), function click() {
filterForm.reset();
filterForm.requestSubmit();
})))), tbody), dom.br(), dom.br(), dom.h2('Change selected messages'), dom.div(style({ display: 'flex', gap: '2em' }), dom.div(dom.div('Hold'), dom.div(dom.clickbutton('On', async function click(e) {
const n = await check(e.target, (async () => await client.QueueHoldSet(gatherIDs(), true))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: reload less
}), ' ', dom.clickbutton('Off', async function click(e) {
const n = await check(e.target, (async () => await client.QueueHoldSet(gatherIDs(), false))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: reload less
}))), dom.div(dom.div('Schedule next delivery attempt'), buttonNextAttemptSet('Now', 0), ' ', dom.clickbutton('More...', function click(e) {
e.target.replaceWith(dom.div(dom.br(), dom.div('Scheduled time plus'), dom.div(buttonNextAttemptAdd('1m', 1), ' ', buttonNextAttemptAdd('5m', 5), ' ', buttonNextAttemptAdd('30m', 30), ' ', buttonNextAttemptAdd('1h', 60), ' ', buttonNextAttemptAdd('2h', 2 * 60), ' ', buttonNextAttemptAdd('4h', 4 * 60), ' ', buttonNextAttemptAdd('8h', 8 * 60), ' ', buttonNextAttemptAdd('16h', 16 * 60), ' '), dom.br(), dom.div('Now plus'), dom.div(buttonNextAttemptSet('1m', 1), ' ', buttonNextAttemptSet('5m', 5), ' ', buttonNextAttemptSet('30m', 30), ' ', buttonNextAttemptSet('1h', 60), ' ', buttonNextAttemptSet('2h', 2 * 60), ' ', buttonNextAttemptSet('4h', 4 * 60), ' ', buttonNextAttemptSet('8h', 8 * 60), ' ', buttonNextAttemptSet('16h', 16 * 60), ' ')));
})), dom.div(dom.form(dom.label('Require TLS'), requiretlsFieldset = dom.fieldset(requiretls = dom.select(attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), dom.option('Default', attr.value('')), dom.option('With RequireTLS', attr.value('yes')), dom.option('Fallback to insecure', attr.value('no'))), ' ', dom.submitbutton('Change')), async function submit(e) {
e.preventDefault();
e.stopPropagation();
const n = await check(requiretlsFieldset, (async () => await client.QueueRequireTLSSet(gatherIDs(), requiretls.value === '' ? null : requiretls.value === 'yes'))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: only refresh the list window.location.reload(); // todo: only refresh the list
})), dom.td(dom.clickbutton('Remove', async function click(e) { })), dom.div(dom.form(dom.label('Transport'), dom.fieldset(transport = dom.select(attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'), dom.option('(default)', attr.value('')), Object.keys(transports || []).sort().map(t => dom.option(t))), ' ', dom.submitbutton('Change')), async function submit(e) {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { e.stopPropagation();
const n = await check(e.target, (async () => await client.QueueTransportSet(gatherIDs(), transport.value))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: only refresh the list
})), dom.div(dom.div('Delivery'), dom.clickbutton('Fail delivery', attr.title('Cause delivery to fail, sending a DSN to the sender.'), async function click(e) {
e.preventDefault();
if (!window.confirm('Are you sure you want to remove this message? Notifications of delivery failure will be sent (DSNs).')) {
return; return;
} }
await check(e.target, client.QueueDrop(m.ID)); const n = await check(e.target, (async () => await client.QueueFail(gatherIDs()))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: only refresh the list
})), dom.div(dom.div('Messages'), dom.clickbutton('Remove', attr.title('Completely remove messages from queue, not sending a DSN.'), async function click(e) {
e.preventDefault();
if (!window.confirm('Are you sure you want to remove this message? It will be removed completely, no DSN about failure to deliver will be sent.')) {
return;
}
const n = await check(e.target, (async () => await client.QueueDrop(gatherIDs()))());
window.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: only refresh the list window.location.reload(); // todo: only refresh the list
})));
})))); }))));
}; };
const webserver = async () => { const webserver = async () => {

View file

@ -2136,101 +2136,403 @@ const dnsbl = async () => {
} }
const queueList = async () => { const queueList = async () => {
const [msgs, transports] = await Promise.all([ let [holdRules, msgs, transports] = await Promise.all([
client.QueueList(), client.QueueHoldRuleList(),
client.QueueList({IDs: [], Account: '', From: '', To: '', Hold: null, Submitted: '', NextAttempt: '', Transport: null}),
client.Transports(), client.Transports(),
]) ])
// todo: sorting by address/timestamps/attempts.
// todo: after making changes, don't reload entire page. probably best to fetch messages by id and rerender. also report on which messages weren't affected (e.g. no longer in queue).
// todo: display which transport will be used for a message according to routing rules (in case none is explicitly configured).
// todo: live updates with SSE connections
// todo: keep updating times/age.
const nowSecs = new Date().getTime()/1000 const nowSecs = new Date().getTime()/1000
let holdRuleAccount: HTMLInputElement
let holdRuleSenderDomain: HTMLInputElement
let holdRuleRecipientDomain: HTMLInputElement
let holdRuleSubmit: HTMLButtonElement
let filterForm: HTMLFormElement
let filterAccount: HTMLInputElement
let filterFrom: HTMLInputElement
let filterTo: HTMLInputElement
let filterSubmitted: HTMLInputElement
let filterHold: HTMLSelectElement
let filterNextAttempt: HTMLInputElement
let filterTransport: HTMLSelectElement
let requiretlsFieldset: HTMLFieldSetElement
let requiretls: HTMLSelectElement
let transport: HTMLSelectElement
// Message ID to checkbox.
let toggles = new Map<number, HTMLInputElement>()
// We operate on what the user has selected, not what the filters would currently
// evaluate to. This function can throw an error, which is why we have awkward
// syntax when calling this as parameter in api client calls below.
const gatherIDs = () => {
const f: api.Filter = {
IDs: Array.from(toggles.entries()).filter(t => t[1].checked).map(t => t[0]),
Account: '',
From: '',
To: '',
Hold: null,
Submitted: '',
NextAttempt: '',
Transport: null,
}
// Don't want to accidentally operate on all messages.
if ((f.IDs || []).length === 0) {
throw new Error('No messages selected.')
}
return f
}
const tbody = dom.tbody()
const render = () => {
toggles = new Map<number, HTMLInputElement>()
for (const m of (msgs || [])) {
toggles.set(m.ID, dom.input(attr.type('checkbox'), attr.checked(''), ))
}
dom._kids(tbody,
(msgs || []).length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No messages.')) : [],
(msgs || []).map(m => {
return dom.tr(
dom.td(toggles.get(m.ID)!),
dom.td(''+m.ID + (m.BaseID > 0 ? '/'+m.BaseID : '')),
dom.td(age(new Date(m.Queued), false, nowSecs)),
dom.td(m.SenderAccount || '-'),
dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart
dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart
dom.td(formatSize(m.Size)),
dom.td(''+m.Attempts),
dom.td(m.Hold ? 'Hold' : ''),
dom.td(age(new Date(m.NextAttempt), true, nowSecs)),
dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'),
dom.td(m.LastError || '-'),
dom.td(m.RequireTLS === true ? 'Yes' : (m.RequireTLS === false ? 'No' : 'Default')),
dom.td(m.Transport || '(default)'),
)
}),
)
}
render()
const buttonNextAttemptSet = (text: string, minutes: number) => dom.clickbutton(text, async function click(e: MouseEvent) {
// note: awkward client call because gatherIDs() can throw an exception.
const n = await check(e.target! as HTMLButtonElement, (async () => client.QueueNextAttemptSet(gatherIDs(), minutes))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: reload less
})
const buttonNextAttemptAdd = (text: string, minutes: number) => dom.clickbutton(text, async function click(e: MouseEvent) {
const n = await check(e.target! as HTMLButtonElement, (async () => client.QueueNextAttemptAdd(gatherIDs(), minutes))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: reload less
})
dom._kids(page, dom._kids(page,
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Queue', 'Queue',
), ),
// todo: sorting by address/timestamps/attempts. perhaps filtering. dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')),
dom.form(
attr.id('holdRuleForm'),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
const pr: api.HoldRule = {
ID: 0,
Account: holdRuleAccount.value,
SenderDomainStr: holdRuleSenderDomain.value,
RecipientDomainStr: holdRuleRecipientDomain.value,
// Filled in by backend, we provide dummy values.
SenderDomain: {ASCII: '', Unicode: ''},
RecipientDomain: {ASCII: '', Unicode: ''},
}
await check(holdRuleSubmit, client.QueueHoldRuleAdd(pr))
window.location.reload() // todo: reload less
},
),
(function() {
// We don't show the full form until asked. Too much visual clutter.
let show = (holdRules || []).length > 0
const box = dom.div()
const renderHoldRules = () => {
dom._kids(box, !show ?
dom.div('No hold rules. ',
dom.clickbutton('Add', function click() {
show = true
renderHoldRules()
}),
) : [
dom.p('Newly submitted messages matching a hold rule will be marked as "on hold" and not be delivered until further action by the admin. To create a rule matching all messages, leave all fields empty.'),
dom.table(
dom.thead(
dom.tr(
dom.th('Account'),
dom.th('Sender domain'),
dom.th('Recipient domain'),
dom.th('Action'),
),
),
dom.tbody(
(holdRules || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No hold rules.')) : [],
(holdRules || []).map(pr =>
dom.tr(
!pr.Account && !pr.SenderDomainStr && !pr.RecipientDomainStr ?
dom.td(attr.colspan('3'), '(Match all messages)') : [
dom.td(pr.Account),
dom.td(domainString(pr.SenderDomain)),
dom.td(domainString(pr.RecipientDomain)),
],
dom.td(
dom.clickbutton('Remove', attr.title('Removing a hold rule does not modify the "on hold" status of messages in the queue.'), async function click(e: MouseEvent) {
await check(e.target! as HTMLButtonElement, client.QueueHoldRuleRemove(pr.ID))
window.location.reload() // todo: reload less
})
),
)
),
dom.tr(
dom.td(holdRuleAccount=dom.input(attr.form('holdRuleForm'))),
dom.td(holdRuleSenderDomain=dom.input(attr.form('holdRuleForm'))),
dom.td(holdRuleRecipientDomain=dom.input(attr.form('holdRuleForm'))),
dom.td(holdRuleSubmit=dom.submitbutton('Add hold rule', attr.form('holdRuleForm'), attr.title('When adding a new hold rule, existing messages in queue matching the new rule will be marked as on hold.'))),
),
),
)
]
)
}
renderHoldRules()
return box
})(),
dom.br(),
// Filtering.
filterForm=dom.form(
attr.id('queuefilter'), // Referenced by input elements in table row.
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
const filter: api.Filter = {
IDs: [],
Account: filterAccount.value,
From: filterFrom.value,
To: filterTo.value,
Hold: filterHold.value === 'Yes' ? true : (filterHold.value === 'No' ? false : null),
Submitted: filterSubmitted.value,
NextAttempt: filterNextAttempt.value,
Transport: !filterTransport.value ? null : (filterTransport.value === '(default)' ? '' : filterTransport.value),
}
dom._kids(tbody)
msgs = await check({disabled: false}, client.QueueList(filter))
render()
},
),
dom.h2('Messages'),
dom.table(dom._class('hover'), dom.table(dom._class('hover'),
dom.thead( dom.thead(
dom.tr( dom.tr(
dom.th(),
dom.th('ID'), dom.th('ID'),
dom.th('Submitted'), dom.th('Submitted'),
dom.th('Account'),
dom.th('From'), dom.th('From'),
dom.th('To'), dom.th('To'),
dom.th('Size'), dom.th('Size'),
dom.th('Attempts'), dom.th('Attempts'),
dom.th('Hold'),
dom.th('Next attempt'), dom.th('Next attempt'),
dom.th('Last attempt'), dom.th('Last attempt'),
dom.th('Last error'), dom.th('Last error'),
dom.th('Require TLS'), dom.th('Require TLS'),
dom.th('Transport/Retry'), dom.th('Transport'),
dom.th('Remove'), dom.th(),
), ),
), dom.tr(
dom.tbody(
(msgs || []).length === 0 ? dom.tr(dom.td(attr.colspan('12'), 'Currently no messages in the queue.')) : [],
(msgs || []).map(m => {
let requiretlsFieldset: HTMLFieldSetElement
let requiretls: HTMLSelectElement
let transport: HTMLSelectElement
return dom.tr(
dom.td(''+m.ID + (m.BaseID > 0 ? '/'+m.BaseID : '')),
dom.td(age(new Date(m.Queued), false, nowSecs)),
dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart
dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart
dom.td(formatSize(m.Size)),
dom.td(''+m.Attempts),
dom.td(age(new Date(m.NextAttempt), true, nowSecs)),
dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'),
dom.td(m.LastError || '-'),
dom.td( dom.td(
dom.input(attr.type('checkbox'), attr.checked(''), attr.form('queuefilter'), function change(e: MouseEvent) {
const elem = e.target! as HTMLInputElement
for (const [_, toggle] of toggles) {
toggle.checked = elem.checked
}
}),
),
dom.td(),
dom.td(filterSubmitted=dom.input(attr.form('queuefilter'), style({width: '7em'}), attr.title('Example: "<1h" for filtering messages submitted more than 1 minute ago.'))),
dom.td(filterAccount=dom.input(attr.form('queuefilter'))),
dom.td(filterFrom=dom.input(attr.form('queuefilter')), attr.title('Example: "@sender.example" to filter by domain of sender.')),
dom.td(filterTo=dom.input(attr.form('queuefilter')), attr.title('Example: "@recipient.example" to filter by domain of recipient.')),
dom.td(), // todo: add filter by size?
dom.td(), // todo: add filter by attempts?
dom.td(
filterHold=dom.select(
attr.form('queuefilter'),
dom.option('', attr.value('')),
dom.option('Yes'),
dom.option('No'),
function change() {
filterForm.requestSubmit()
},
),
),
dom.td(filterNextAttempt=dom.input(attr.form('queuefilter'), style({width: '7em'}), attr.title('Example: ">1h" for filtering messages to be delivered in more than 1 hour, or "<now" for messages to be delivered as soon as possible.'))),
dom.td(),
dom.td(),
dom.td(),
dom.td(
filterTransport=dom.select(
Object.keys(transports || []).length === 0 ? style({display: 'none'}) : [],
attr.form('queuefilter'),
function change() {
filterForm.requestSubmit()
},
dom.option(''),
dom.option('(default)'),
Object.keys(transports || []).sort().map(t => dom.option(t))
),
),
dom.td(
dom.submitbutton('Filter', attr.form('queuefilter')), ' ',
dom.clickbutton('Reset', attr.form('queuefilter'), function click() {
filterForm.reset()
filterForm.requestSubmit()
}),
),
),
),
tbody,
),
dom.br(),
dom.br(),
dom.h2('Change selected messages'),
dom.div(
style({display: 'flex', gap: '2em'}),
dom.div(
dom.div('Hold'),
dom.div(
dom.clickbutton('On', async function click(e: MouseEvent) {
const n = await check(e.target! as HTMLButtonElement, (async () => await client.QueueHoldSet(gatherIDs(), true))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: reload less
}), ' ',
dom.clickbutton('Off', async function click(e: MouseEvent) {
const n = await check(e.target! as HTMLButtonElement, (async () => await client.QueueHoldSet(gatherIDs(), false))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: reload less
}),
),
),
dom.div(
dom.div('Schedule next delivery attempt'),
buttonNextAttemptSet('Now', 0), ' ',
dom.clickbutton('More...', function click(e: MouseEvent) {
(e.target! as HTMLButtonElement).replaceWith(
dom.div(
dom.br(),
dom.div('Scheduled time plus'),
dom.div(
buttonNextAttemptAdd('1m', 1), ' ',
buttonNextAttemptAdd('5m', 5), ' ',
buttonNextAttemptAdd('30m', 30), ' ',
buttonNextAttemptAdd('1h', 60), ' ',
buttonNextAttemptAdd('2h', 2*60), ' ',
buttonNextAttemptAdd('4h', 4*60), ' ',
buttonNextAttemptAdd('8h', 8*60), ' ',
buttonNextAttemptAdd('16h', 16*60), ' ',
),
dom.br(),
dom.div('Now plus'),
dom.div(
buttonNextAttemptSet('1m', 1), ' ',
buttonNextAttemptSet('5m', 5), ' ',
buttonNextAttemptSet('30m', 30), ' ',
buttonNextAttemptSet('1h', 60), ' ',
buttonNextAttemptSet('2h', 2*60), ' ',
buttonNextAttemptSet('4h', 4*60), ' ',
buttonNextAttemptSet('8h', 8*60), ' ',
buttonNextAttemptSet('16h', 16*60), ' ',
)
)
)
}),
),
dom.div(
dom.form( dom.form(
dom.label('Require TLS'),
requiretlsFieldset=dom.fieldset( requiretlsFieldset=dom.fieldset(
requiretls=dom.select( requiretls=dom.select(
attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'),
dom.option('Default', attr.value('')), dom.option('Default', attr.value('')),
dom.option('With RequireTLS', attr.value('yes'), m.RequireTLS === true ? attr.selected('') : []), dom.option('With RequireTLS', attr.value('yes')),
dom.option('Fallback to insecure', attr.value('no'), m.RequireTLS === false ? attr.selected('') : []), dom.option('Fallback to insecure', attr.value('no')),
), ),
' ', ' ',
dom.submitbutton('Save'), dom.submitbutton('Change'),
), ),
async function submit(e: SubmitEvent) { async function submit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
await check(requiretlsFieldset, client.QueueSaveRequireTLS(m.ID, requiretls.value === '' ? null : requiretls.value === 'yes')) const n = await check(requiretlsFieldset, (async () => await client.QueueRequireTLSSet(gatherIDs(), requiretls.value === '' ? null : requiretls.value === 'yes'))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: only refresh the list
} }
), ),
), ),
dom.td( dom.div(
dom.form( dom.form(
dom.label('Transport'),
dom.fieldset(
transport=dom.select( transport=dom.select(
attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'), attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'),
dom.option('(default)', attr.value('')), dom.option('(default)', attr.value('')),
Object.keys(transports || []).sort().map(t => dom.option(t, m.Transport === t ? attr.checked('') : [])), Object.keys(transports || []).sort().map(t => dom.option(t)),
), ),
' ', ' ',
dom.submitbutton('Retry now'), dom.submitbutton('Change'),
),
async function submit(e: SubmitEvent) { async function submit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
await check(e.target! as HTMLButtonElement, client.QueueKick(m.ID, transport.value)) const n = await check(e.target! as HTMLButtonElement, (async () => await client.QueueTransportSet(gatherIDs(), transport.value))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: only refresh the list window.location.reload() // todo: only refresh the list
} }
), ),
), ),
dom.td( dom.div(
dom.clickbutton('Remove', async function click(e: MouseEvent) { dom.div('Delivery'),
dom.clickbutton('Fail delivery', attr.title('Cause delivery to fail, sending a DSN to the sender.'), async function click(e: MouseEvent) {
e.preventDefault() e.preventDefault()
if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { if (!window.confirm('Are you sure you want to remove this message? Notifications of delivery failure will be sent (DSNs).')) {
return return
} }
await check(e.target! as HTMLButtonElement, client.QueueDrop(m.ID)) const n = await check(e.target! as HTMLButtonElement, (async () => await client.QueueFail(gatherIDs()))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: only refresh the list window.location.reload() // todo: only refresh the list
}), }),
), ),
) dom.div(
}) dom.div('Messages'),
dom.clickbutton('Remove', attr.title('Completely remove messages from queue, not sending a DSN.'), async function click(e: MouseEvent) {
e.preventDefault()
if (!window.confirm('Are you sure you want to remove this message? It will be removed completely, no DSN about failure to deliver will be sent.')) {
return
}
const n = await check(e.target! as HTMLButtonElement, (async () => await client.QueueDrop(gatherIDs()))())
window.alert(''+n+' message(s) updated')
window.location.reload() // todo: only refresh the list
}),
), ),
), ),
) )

View file

@ -673,20 +673,6 @@
} }
] ]
}, },
{
"Name": "QueueList",
"Docs": "QueueList returns the messages currently in the outgoing queue.",
"Params": [],
"Returns": [
{
"Name": "r0",
"Typewords": [
"[]",
"Msg"
]
}
]
},
{ {
"Name": "QueueSize", "Name": "QueueSize",
"Docs": "QueueSize returns the number of messages currently in the outgoing queue.", "Docs": "QueueSize returns the number of messages currently in the outgoing queue.",
@ -701,45 +687,199 @@
] ]
}, },
{ {
"Name": "QueueKick", "Name": "QueueHoldRuleList",
"Docs": "QueueKick initiates delivery of a message from the queue and sets the transport\nto use for delivery.", "Docs": "QueueHoldRuleList lists the hold rules.",
"Params": [ "Params": [],
"Returns": [
{ {
"Name": "id", "Name": "r0",
"Typewords": [ "Typewords": [
"int64" "[]",
"HoldRule"
]
}
] ]
}, },
{ {
"Name": "transport", "Name": "QueueHoldRuleAdd",
"Docs": "QueueHoldRuleAdd adds a hold rule. Newly submitted and existing messages\nmatching the hold rule will be marked \"on hold\".",
"Params": [
{
"Name": "hr",
"Typewords": [ "Typewords": [
"string" "HoldRule"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"HoldRule"
]
}
]
},
{
"Name": "QueueHoldRuleRemove",
"Docs": "QueueHoldRuleRemove removes a hold rule. The Hold field of messages in\nthe queue are not changed.",
"Params": [
{
"Name": "holdRuleID",
"Typewords": [
"int64"
] ]
} }
], ],
"Returns": [] "Returns": []
}, },
{
"Name": "QueueList",
"Docs": "QueueList returns the messages currently in the outgoing queue.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"[]",
"Msg"
]
}
]
},
{
"Name": "QueueNextAttemptSet",
"Docs": "QueueNextAttemptSet sets a new time for next delivery attempt of matching\nmessages from the queue.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
},
{
"Name": "minutes",
"Typewords": [
"int32"
]
}
],
"Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
},
{
"Name": "QueueNextAttemptAdd",
"Docs": "QueueNextAttemptAdd adds a duration to the time of next delivery attempt of\nmatching messages from the queue.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
},
{
"Name": "minutes",
"Typewords": [
"int32"
]
}
],
"Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
},
{
"Name": "QueueHoldSet",
"Docs": "QueueHoldSet sets the Hold field of matching messages in the queue.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
},
{
"Name": "onHold",
"Typewords": [
"bool"
]
}
],
"Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
},
{
"Name": "QueueFail",
"Docs": "QueueFail fails delivery for matching messages, causing DSNs to be sent.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
}
],
"Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
},
{ {
"Name": "QueueDrop", "Name": "QueueDrop",
"Docs": "QueueDrop removes a message from the queue.", "Docs": "QueueDrop removes matching messages from the queue.",
"Params": [ "Params": [
{ {
"Name": "id", "Name": "filter",
"Typewords": [ "Typewords": [
"int64" "Filter"
] ]
} }
], ],
"Returns": [] "Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
}, },
{ {
"Name": "QueueSaveRequireTLS", "Name": "QueueRequireTLSSet",
"Docs": "QueueSaveRequireTLS updates the requiretls field for a message in the queue,\nto be used for the next delivery.", "Docs": "QueueRequireTLSSet updates the requiretls field for matching messages in the\nqueue, to be used for the next delivery.",
"Params": [ "Params": [
{ {
"Name": "id", "Name": "filter",
"Typewords": [ "Typewords": [
"int64" "Filter"
] ]
}, },
{ {
@ -750,7 +890,40 @@
] ]
} }
], ],
"Returns": [] "Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
},
{
"Name": "QueueTransportSet",
"Docs": "QueueTransportSet initiates delivery of a message from the queue and sets the transport\nto use for delivery.",
"Params": [
{
"Name": "filter",
"Typewords": [
"Filter"
]
},
{
"Name": "transport",
"Typewords": [
"string"
]
}
],
"Returns": [
{
"Name": "affected",
"Typewords": [
"int32"
]
}
]
}, },
{ {
"Name": "LogLevels", "Name": "LogLevels",
@ -3371,6 +3544,119 @@
} }
] ]
}, },
{
"Name": "HoldRule",
"Docs": "HoldRule is a set of conditions that cause a matching message to be marked as on\nhold when it is queued. All-empty conditions matches all messages, effectively\npausing the entire queue.",
"Fields": [
{
"Name": "ID",
"Docs": "",
"Typewords": [
"int64"
]
},
{
"Name": "Account",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "SenderDomain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "RecipientDomain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "SenderDomainStr",
"Docs": "Unicode.",
"Typewords": [
"string"
]
},
{
"Name": "RecipientDomainStr",
"Docs": "Unicode.",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Filter",
"Docs": "Filter filters messages to list or operate on. Used by admin web interface\nand cli.\n\nOnly non-empty/non-zero values are applied to the filter. Leaving all fields\nempty/zero matches all messages.",
"Fields": [
{
"Name": "IDs",
"Docs": "",
"Typewords": [
"[]",
"int64"
]
},
{
"Name": "Account",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "From",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "To",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Hold",
"Docs": "",
"Typewords": [
"nullable",
"bool"
]
},
{
"Name": "Submitted",
"Docs": "Whether submitted before/after a time relative to now. \"\u003e$duration\" or \"\u003c$duration\", also with \"now\" for duration.",
"Typewords": [
"string"
]
},
{
"Name": "NextAttempt",
"Docs": "\"\u003e$duration\" or \"\u003c$duration\", also with \"now\" for duration.",
"Typewords": [
"string"
]
},
{
"Name": "Transport",
"Docs": "",
"Typewords": [
"nullable",
"string"
]
}
]
},
{ {
"Name": "Msg", "Name": "Msg",
"Docs": "Msg is a message in the queue.\n\nUse MakeMsg to make a message with fields that Add needs. Add will further set\nqueueing related fields.", "Docs": "Msg is a message in the queue.\n\nUse MakeMsg to make a message with fields that Add needs. Add will further set\nqueueing related fields.",
@ -3396,6 +3682,13 @@
"timestamp" "timestamp"
] ]
}, },
{
"Name": "Hold",
"Docs": "If set, delivery won't be attempted.",
"Typewords": [
"bool"
]
},
{ {
"Name": "SenderAccount", "Name": "SenderAccount",
"Docs": "Failures are delivered back to this local account. Also used for routing.", "Docs": "Failures are delivered back to this local account. Also used for routing.",
@ -3417,6 +3710,13 @@
"IPDomain" "IPDomain"
] ]
}, },
{
"Name": "SenderDomainStr",
"Docs": "For filtering, unicode.",
"Typewords": [
"string"
]
},
{ {
"Name": "RecipientLocalpart", "Name": "RecipientLocalpart",
"Docs": "Typically a remote user and domain.", "Docs": "Typically a remote user and domain.",
@ -3433,7 +3733,7 @@
}, },
{ {
"Name": "RecipientDomainStr", "Name": "RecipientDomainStr",
"Docs": "For filtering.", "Docs": "For filtering, unicode.",
"Typewords": [ "Typewords": [
"string" "string"
] ]

View file

@ -465,6 +465,34 @@ export interface ClientConfigsEntry {
Note: string Note: string
} }
// HoldRule is a set of conditions that cause a matching message to be marked as on
// hold when it is queued. All-empty conditions matches all messages, effectively
// pausing the entire queue.
export interface HoldRule {
ID: number
Account: string
SenderDomain: Domain
RecipientDomain: Domain
SenderDomainStr: string // Unicode.
RecipientDomainStr: string // Unicode.
}
// Filter filters messages to list or operate on. Used by admin web interface
// and cli.
//
// Only non-empty/non-zero values are applied to the filter. Leaving all fields
// empty/zero matches all messages.
export interface Filter {
IDs?: number[] | null
Account: string
From: string
To: string
Hold?: boolean | null
Submitted: string // Whether submitted before/after a time relative to now. ">$duration" or "<$duration", also with "now" for duration.
NextAttempt: string // ">$duration" or "<$duration", also with "now" for duration.
Transport?: string | null
}
// Msg is a message in the queue. // Msg is a message in the queue.
// //
// Use MakeMsg to make a message with fields that Add needs. Add will further set // Use MakeMsg to make a message with fields that Add needs. Add will further set
@ -473,12 +501,14 @@ export interface Msg {
ID: number ID: number
BaseID: number // A message for multiple recipients will get a BaseID that is identical to the first Msg.ID queued. The message contents will be identical for each recipient, including MsgPrefix. If other properties are identical too, including recipient domain, multiple Msgs may be delivered in a single SMTP transaction. For messages with a single recipient, this field will be 0. BaseID: number // A message for multiple recipients will get a BaseID that is identical to the first Msg.ID queued. The message contents will be identical for each recipient, including MsgPrefix. If other properties are identical too, including recipient domain, multiple Msgs may be delivered in a single SMTP transaction. For messages with a single recipient, this field will be 0.
Queued: Date Queued: Date
Hold: boolean // If set, delivery won't be attempted.
SenderAccount: string // Failures are delivered back to this local account. Also used for routing. SenderAccount: string // Failures are delivered back to this local account. Also used for routing.
SenderLocalpart: Localpart // Should be a local user and domain. SenderLocalpart: Localpart // Should be a local user and domain.
SenderDomain: IPDomain SenderDomain: IPDomain
SenderDomainStr: string // For filtering, unicode.
RecipientLocalpart: Localpart // Typically a remote user and domain. RecipientLocalpart: Localpart // Typically a remote user and domain.
RecipientDomain: IPDomain RecipientDomain: IPDomain
RecipientDomainStr: string // For filtering. RecipientDomainStr: string // For filtering, unicode.
Attempts: number // Next attempt is based on last attempt and exponential back off based on attempts. Attempts: number // Next attempt is based on last attempt and exponential back off based on attempts.
MaxAttempts: number // Max number of attempts before giving up. If 0, then the default of 8 attempts is used instead. MaxAttempts: number // Max number of attempts before giving up. If 0, then the default of 8 attempts is used instead.
DialedIPs?: { [key: string]: IP[] | null } // For each host, the IPs that were dialed. Used for IP selection for later attempts. DialedIPs?: { [key: string]: IP[] | null } // For each host, the IPs that were dialed. Used for IP selection for later attempts.
@ -780,7 +810,7 @@ export type Localpart = string
// be an IPv4 address. // be an IPv4 address.
export type IP = string export type IP = string
export const structTypes: {[typename: string]: boolean} = {"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"DANECheckResult":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"Reverse":true,"Row":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} export const structTypes: {[typename: string]: boolean} = {"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"DANECheckResult":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"Reverse":true,"Row":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"CSRFToken":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true} export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"CSRFToken":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true}
export const intsTypes: {[typename: string]: boolean} = {} export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = { export const types: TypenameMap = {
@ -840,7 +870,9 @@ export const types: TypenameMap = {
"Reverse": {"Name":"Reverse","Docs":"","Fields":[{"Name":"Hostnames","Docs":"","Typewords":["[]","string"]}]}, "Reverse": {"Name":"Reverse","Docs":"","Fields":[{"Name":"Hostnames","Docs":"","Typewords":["[]","string"]}]},
"ClientConfigs": {"Name":"ClientConfigs","Docs":"","Fields":[{"Name":"Entries","Docs":"","Typewords":["[]","ClientConfigsEntry"]}]}, "ClientConfigs": {"Name":"ClientConfigs","Docs":"","Fields":[{"Name":"Entries","Docs":"","Typewords":["[]","ClientConfigsEntry"]}]},
"ClientConfigsEntry": {"Name":"ClientConfigsEntry","Docs":"","Fields":[{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["Domain"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"Listener","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]}]}, "ClientConfigsEntry": {"Name":"ClientConfigsEntry","Docs":"","Fields":[{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["Domain"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"Listener","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]}]},
"Msg": {"Name":"Msg","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"BaseID","Docs":"","Typewords":["int64"]},{"Name":"Queued","Docs":"","Typewords":["timestamp"]},{"Name":"SenderAccount","Docs":"","Typewords":["string"]},{"Name":"SenderLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"SenderDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"RecipientDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"MaxAttempts","Docs":"","Typewords":["int32"]},{"Name":"DialedIPs","Docs":"","Typewords":["{}","[]","IP"]},{"Name":"NextAttempt","Docs":"","Typewords":["timestamp"]},{"Name":"LastAttempt","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"LastError","Docs":"","Typewords":["string"]},{"Name":"Has8bit","Docs":"","Typewords":["bool"]},{"Name":"SMTPUTF8","Docs":"","Typewords":["bool"]},{"Name":"IsDMARCReport","Docs":"","Typewords":["bool"]},{"Name":"IsTLSReport","Docs":"","Typewords":["bool"]},{"Name":"Size","Docs":"","Typewords":["int64"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"MsgPrefix","Docs":"","Typewords":["nullable","string"]},{"Name":"DSNUTF8","Docs":"","Typewords":["nullable","string"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureReleaseRequest","Docs":"","Typewords":["string"]}]}, "HoldRule": {"Name":"HoldRule","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"SenderDomain","Docs":"","Typewords":["Domain"]},{"Name":"RecipientDomain","Docs":"","Typewords":["Domain"]},{"Name":"SenderDomainStr","Docs":"","Typewords":["string"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]}]},
"Filter": {"Name":"Filter","Docs":"","Fields":[{"Name":"IDs","Docs":"","Typewords":["[]","int64"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["string"]},{"Name":"Hold","Docs":"","Typewords":["nullable","bool"]},{"Name":"Submitted","Docs":"","Typewords":["string"]},{"Name":"NextAttempt","Docs":"","Typewords":["string"]},{"Name":"Transport","Docs":"","Typewords":["nullable","string"]}]},
"Msg": {"Name":"Msg","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"BaseID","Docs":"","Typewords":["int64"]},{"Name":"Queued","Docs":"","Typewords":["timestamp"]},{"Name":"Hold","Docs":"","Typewords":["bool"]},{"Name":"SenderAccount","Docs":"","Typewords":["string"]},{"Name":"SenderLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"SenderDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"SenderDomainStr","Docs":"","Typewords":["string"]},{"Name":"RecipientLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"RecipientDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"MaxAttempts","Docs":"","Typewords":["int32"]},{"Name":"DialedIPs","Docs":"","Typewords":["{}","[]","IP"]},{"Name":"NextAttempt","Docs":"","Typewords":["timestamp"]},{"Name":"LastAttempt","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"LastError","Docs":"","Typewords":["string"]},{"Name":"Has8bit","Docs":"","Typewords":["bool"]},{"Name":"SMTPUTF8","Docs":"","Typewords":["bool"]},{"Name":"IsDMARCReport","Docs":"","Typewords":["bool"]},{"Name":"IsTLSReport","Docs":"","Typewords":["bool"]},{"Name":"Size","Docs":"","Typewords":["int64"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"MsgPrefix","Docs":"","Typewords":["nullable","string"]},{"Name":"DSNUTF8","Docs":"","Typewords":["nullable","string"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureReleaseRequest","Docs":"","Typewords":["string"]}]},
"IPDomain": {"Name":"IPDomain","Docs":"","Fields":[{"Name":"IP","Docs":"","Typewords":["IP"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "IPDomain": {"Name":"IPDomain","Docs":"","Fields":[{"Name":"IP","Docs":"","Typewords":["IP"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"WebserverConfig": {"Name":"WebserverConfig","Docs":"","Fields":[{"Name":"WebDNSDomainRedirects","Docs":"","Typewords":["[]","[]","Domain"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["[]","[]","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]}]}, "WebserverConfig": {"Name":"WebserverConfig","Docs":"","Fields":[{"Name":"WebDNSDomainRedirects","Docs":"","Typewords":["[]","[]","Domain"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["[]","[]","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]}]},
"WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]}, "WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]},
@ -931,6 +963,8 @@ export const parser = {
Reverse: (v: any) => parse("Reverse", v) as Reverse, Reverse: (v: any) => parse("Reverse", v) as Reverse,
ClientConfigs: (v: any) => parse("ClientConfigs", v) as ClientConfigs, ClientConfigs: (v: any) => parse("ClientConfigs", v) as ClientConfigs,
ClientConfigsEntry: (v: any) => parse("ClientConfigsEntry", v) as ClientConfigsEntry, ClientConfigsEntry: (v: any) => parse("ClientConfigsEntry", v) as ClientConfigsEntry,
HoldRule: (v: any) => parse("HoldRule", v) as HoldRule,
Filter: (v: any) => parse("Filter", v) as Filter,
Msg: (v: any) => parse("Msg", v) as Msg, Msg: (v: any) => parse("Msg", v) as Msg,
IPDomain: (v: any) => parse("IPDomain", v) as IPDomain, IPDomain: (v: any) => parse("IPDomain", v) as IPDomain,
WebserverConfig: (v: any) => parse("WebserverConfig", v) as WebserverConfig, WebserverConfig: (v: any) => parse("WebserverConfig", v) as WebserverConfig,
@ -1295,15 +1329,6 @@ export class Client {
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ClientConfigs return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ClientConfigs
} }
// QueueList returns the messages currently in the outgoing queue.
async QueueList(): Promise<Msg[] | null> {
const fn: string = "QueueList"
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","Msg"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Msg[] | null
}
// QueueSize returns the number of messages currently in the outgoing queue. // QueueSize returns the number of messages currently in the outgoing queue.
async QueueSize(): Promise<number> { async QueueSize(): Promise<number> {
const fn: string = "QueueSize" const fn: string = "QueueSize"
@ -1313,33 +1338,109 @@ export class Client {
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
} }
// QueueKick initiates delivery of a message from the queue and sets the transport // QueueHoldRuleList lists the hold rules.
// to use for delivery. async QueueHoldRuleList(): Promise<HoldRule[] | null> {
async QueueKick(id: number, transport: string): Promise<void> { const fn: string = "QueueHoldRuleList"
const fn: string = "QueueKick" const paramTypes: string[][] = []
const paramTypes: string[][] = [["int64"],["string"]] const returnTypes: string[][] = [["[]","HoldRule"]]
const returnTypes: string[][] = [] const params: any[] = []
const params: any[] = [id, transport] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as HoldRule[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
} }
// QueueDrop removes a message from the queue. // QueueHoldRuleAdd adds a hold rule. Newly submitted and existing messages
async QueueDrop(id: number): Promise<void> { // matching the hold rule will be marked "on hold".
const fn: string = "QueueDrop" async QueueHoldRuleAdd(hr: HoldRule): Promise<HoldRule> {
const fn: string = "QueueHoldRuleAdd"
const paramTypes: string[][] = [["HoldRule"]]
const returnTypes: string[][] = [["HoldRule"]]
const params: any[] = [hr]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as HoldRule
}
// QueueHoldRuleRemove removes a hold rule. The Hold field of messages in
// the queue are not changed.
async QueueHoldRuleRemove(holdRuleID: number): Promise<void> {
const fn: string = "QueueHoldRuleRemove"
const paramTypes: string[][] = [["int64"]] const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = [] const returnTypes: string[][] = []
const params: any[] = [id] const params: any[] = [holdRuleID]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
} }
// QueueSaveRequireTLS updates the requiretls field for a message in the queue, // QueueList returns the messages currently in the outgoing queue.
// to be used for the next delivery. async QueueList(filter: Filter): Promise<Msg[] | null> {
async QueueSaveRequireTLS(id: number, requireTLS: boolean | null): Promise<void> { const fn: string = "QueueList"
const fn: string = "QueueSaveRequireTLS" const paramTypes: string[][] = [["Filter"]]
const paramTypes: string[][] = [["int64"],["nullable","bool"]] const returnTypes: string[][] = [["[]","Msg"]]
const returnTypes: string[][] = [] const params: any[] = [filter]
const params: any[] = [id, requireTLS] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Msg[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void }
// QueueNextAttemptSet sets a new time for next delivery attempt of matching
// messages from the queue.
async QueueNextAttemptSet(filter: Filter, minutes: number): Promise<number> {
const fn: string = "QueueNextAttemptSet"
const paramTypes: string[][] = [["Filter"],["int32"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter, minutes]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueNextAttemptAdd adds a duration to the time of next delivery attempt of
// matching messages from the queue.
async QueueNextAttemptAdd(filter: Filter, minutes: number): Promise<number> {
const fn: string = "QueueNextAttemptAdd"
const paramTypes: string[][] = [["Filter"],["int32"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter, minutes]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueHoldSet sets the Hold field of matching messages in the queue.
async QueueHoldSet(filter: Filter, onHold: boolean): Promise<number> {
const fn: string = "QueueHoldSet"
const paramTypes: string[][] = [["Filter"],["bool"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter, onHold]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueFail fails delivery for matching messages, causing DSNs to be sent.
async QueueFail(filter: Filter): Promise<number> {
const fn: string = "QueueFail"
const paramTypes: string[][] = [["Filter"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueDrop removes matching messages from the queue.
async QueueDrop(filter: Filter): Promise<number> {
const fn: string = "QueueDrop"
const paramTypes: string[][] = [["Filter"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueRequireTLSSet updates the requiretls field for matching messages in the
// queue, to be used for the next delivery.
async QueueRequireTLSSet(filter: Filter, requireTLS: boolean | null): Promise<number> {
const fn: string = "QueueRequireTLSSet"
const paramTypes: string[][] = [["Filter"],["nullable","bool"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter, requireTLS]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueTransportSet initiates delivery of a message from the queue and sets the transport
// to use for delivery.
async QueueTransportSet(filter: Filter, transport: string): Promise<number> {
const fn: string = "QueueTransportSet"
const paramTypes: string[][] = [["Filter"],["string"]]
const returnTypes: string[][] = [["int32"]]
const params: any[] = [filter, transport]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
} }
// LogLevels returns the current log levels. // LogLevels returns the current log levels.

View file

@ -219,6 +219,7 @@ const [dom, style, attr, prop] = (function () {
method: (s) => _attr('method', s), method: (s) => _attr('method', s),
autocomplete: (s) => _attr('autocomplete', s), autocomplete: (s) => _attr('autocomplete', s),
list: (s) => _attr('list', s), list: (s) => _attr('list', s),
form: (s) => _attr('form', s),
}; };
const style = (x) => { return { _styles: x }; }; const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; }; const prop = (x) => { return { _props: x }; };

View file

@ -219,6 +219,7 @@ const [dom, style, attr, prop] = (function () {
method: (s) => _attr('method', s), method: (s) => _attr('method', s),
autocomplete: (s) => _attr('autocomplete', s), autocomplete: (s) => _attr('autocomplete', s),
list: (s) => _attr('list', s), list: (s) => _attr('list', s),
form: (s) => _attr('form', s),
}; };
const style = (x) => { return { _styles: x }; }; const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; }; const prop = (x) => { return { _props: x }; };

View file

@ -219,6 +219,7 @@ const [dom, style, attr, prop] = (function () {
method: (s) => _attr('method', s), method: (s) => _attr('method', s),
autocomplete: (s) => _attr('autocomplete', s), autocomplete: (s) => _attr('autocomplete', s),
list: (s) => _attr('list', s), list: (s) => _attr('list', s),
form: (s) => _attr('form', s),
}; };
const style = (x) => { return { _styles: x }; }; const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; }; const prop = (x) => { return { _props: x }; };