From e24e1bee192cda063503f733591e3445ce7adb98 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 13 Nov 2023 13:48:52 +0100 Subject: [PATCH] add suppression list for outgoing dmarc and tls reports for reporting addresses that cause DSNs to be returned. that just adds noise. the admin can add/remove/extend addresses through the webadmin. in the future, we could send reports with a smtp mail from of "postmaster+@...", and add the reporting recipient on the suppression list automatically when a DSN comes in on that address, but for now this will probably do. --- dmarcdb/eval.go | 90 +++++++++++++- dmarcdb/eval_test.go | 14 ++- queue/queue.go | 16 ++- smtpserver/server_test.go | 11 +- testdata/smtp/tlsrpt/mox.conf | 6 +- tlsrptdb/db.go | 2 +- tlsrptdb/result.go | 58 +++++++++ tlsrptsend/send.go | 15 ++- tlsrptsend/send_test.go | 10 ++ webadmin/admin.go | 60 +++++++++ webadmin/admin.html | 208 ++++++++++++++++++++++++++++++- webadmin/adminapi.json | 224 ++++++++++++++++++++++++++++++++++ 12 files changed, 697 insertions(+), 17 deletions(-) diff --git a/dmarcdb/eval.go b/dmarcdb/eval.go index 6408412..4c26e96 100644 --- a/dmarcdb/eval.go +++ b/dmarcdb/eval.go @@ -60,7 +60,7 @@ var ( ) var ( - EvalDBTypes = []any{Evaluation{}} // Types stored in DB. + EvalDBTypes = []any{Evaluation{}, SuppressAddress{}} // Types stored in DB. // Exported for backups. For incoming deliveries the SMTP server adds evaluations // to the database. Every hour, a goroutine wakes up that gathers evaluations from // the last hour(s), sends a report, and removes the evaluations from the database. @@ -119,6 +119,16 @@ type Evaluation struct { SPFResults []dmarcrpt.SPFAuthResult } +// SuppressAddress is a reporting address for which outgoing DMARC reports +// will be suppressed for a period. +type SuppressAddress struct { + ID int64 + Inserted time.Time `bstore:"default now"` + ReportingAddress string `bstore:"unique"` + Until time.Time `bstore:"nonzero"` + Comment string +} + var dmarcResults = map[bool]dmarcrpt.DMARCResult{ false: dmarcrpt.DMARCFail, true: dmarcrpt.DMARCPass, @@ -803,6 +813,19 @@ Period: %s - %s UTC msgSize := int64(len(msgPrefix)) + msgInfo.Size() var queued bool for _, rcpt := range recipients { + // If recipient is on suppression list, we won't queue the reporting message. + q := bstore.QueryDB[SuppressAddress](ctx, db) + q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.address.Path().String()}) + q.FilterGreater("Until", time.Now()) + exists, err := q.Exists() + if err != nil { + return false, fmt.Errorf("querying suppress list: %v", err) + } + if exists { + log.Info("suppressing outgoing dmarc aggregate report", mlog.Field("reportingaddress", rcpt.address)) + continue + } + // Only send to addresses where we don't exceed their size limit. The RFC mentions // the size of the report, but then continues about the size after compression and // transport encodings (i.e. gzip and the mime base64 attachment, so the intention @@ -818,7 +841,7 @@ Period: %s - %s UTC qm.MaxAttempts = 5 qm.IsDMARCReport = true - err := queueAdd(ctx, log, &qm, msgf) + err = queueAdd(ctx, log, &qm, msgf) if err != nil { tempError = true log.Errorx("queueing message with dmarc aggregate report", err) @@ -831,7 +854,7 @@ Period: %s - %s UTC } if !queued { - if err := sendErrorReport(ctx, log, from, addrs, dom, report.ReportMetadata.ReportID, msgSize); err != nil { + if err := sendErrorReport(ctx, log, db, from, addrs, dom, report.ReportMetadata.ReportID, msgSize); err != nil { log.Errorx("sending dmarc error reports", err) metricReportError.Inc() } @@ -917,7 +940,7 @@ func composeAggregateReport(ctx context.Context, log *mlog.Log, mf *os.File, fro // Though this functionality is quite underspecified, we'll do our best to send our // an error report in case our report is too large for all recipients. // ../rfc/7489:1918 -func sendErrorReport(ctx context.Context, log *mlog.Log, fromAddr smtp.Address, recipients []message.NameAddress, reportDomain dns.Domain, reportID string, reportMsgSize int64) error { +func sendErrorReport(ctx context.Context, log *mlog.Log, db *bstore.DB, fromAddr smtp.Address, recipients []message.NameAddress, reportDomain dns.Domain, reportID string, reportMsgSize int64) error { log.Debug("no reporting addresses willing to accept report given size, queuing short error message") msgf, err := store.CreateMessageTemp("dmarcreportmsg-out") @@ -954,6 +977,19 @@ Submitting-URI: %s msgSize := int64(len(msgPrefix)) + msgInfo.Size() for _, rcpt := range recipients { + // If recipient is on suppression list, we won't queue the reporting message. + q := bstore.QueryDB[SuppressAddress](ctx, db) + q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.Address.Path().String()}) + q.FilterGreater("Until", time.Now()) + exists, err := q.Exists() + if err != nil { + return fmt.Errorf("querying suppress list: %v", err) + } + if exists { + log.Info("suppressing outgoing dmarc error report", mlog.Field("reportingaddress", rcpt.Address)) + continue + } + qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil) // Don't try as long as regular deliveries, and stop before we would send the // delayed DSN. Though we also won't send that due to IsDMARCReport. @@ -1044,3 +1080,49 @@ func dkimSign(ctx context.Context, log *mlog.Log, fromAddr smtp.Address, smtputf } return "" } + +// SuppressAdd adds an address to the suppress list. +func SuppressAdd(ctx context.Context, ba *SuppressAddress) error { + db, err := evalDB(ctx) + if err != nil { + return err + } + + return db.Insert(ctx, ba) +} + +// SuppressList returns all reporting addresses on the suppress list. +func SuppressList(ctx context.Context) ([]SuppressAddress, error) { + db, err := evalDB(ctx) + if err != nil { + return nil, err + } + + return bstore.QueryDB[SuppressAddress](ctx, db).SortDesc("ID").List() +} + +// SuppressRemove removes a reporting address record from the suppress list. +func SuppressRemove(ctx context.Context, id int64) error { + db, err := evalDB(ctx) + if err != nil { + return err + } + + return db.Delete(ctx, &SuppressAddress{ID: id}) +} + +// SuppressUpdate updates the until field of a reporting address record. +func SuppressUpdate(ctx context.Context, id int64, until time.Time) error { + db, err := evalDB(ctx) + if err != nil { + return err + } + + ba := SuppressAddress{ID: id} + err = db.Get(ctx, &ba) + if err != nil { + return err + } + ba.Until = until + return db.Update(ctx, &ba) +} diff --git a/dmarcdb/eval_test.go b/dmarcdb/eval_test.go index 8b13ef8..20f3ad9 100644 --- a/dmarcdb/eval_test.go +++ b/dmarcdb/eval_test.go @@ -312,7 +312,7 @@ func TestSendReports(t *testing.T) { if optExpReport != nil { // Parse report in message and compare with expected. - expFeedback.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID + optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID tcompare(t, feedback, expFeedback) } @@ -348,6 +348,18 @@ func TestSendReports(t *testing.T) { evalOpt.Optional = true test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil) + // Address is suppressed. + sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)} + err = db.Insert(ctxbg, &sa) + tcheckf(t, err, "insert suppress address") + test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil) + + // Suppression has expired. + sa.Until = time.Now().Add(-time.Minute) + err = db.Update(ctxbg, &sa) + tcheckf(t, err, "update suppress address") + test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback) + // Two RUA's, one with a size limit that doesn't pass, and one that does pass. resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"} test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil) diff --git a/queue/queue.go b/queue/queue.go index ba8c626..2f85e3a 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -605,6 +605,20 @@ func deliver(resolver dns.Resolver, m Msg) { now := time.Now() dayUTC := now.UTC().Format("20060102") + // See if this contains a failure. If not, we'll mark TLS results for delivering + // DMARC reports SendReport false, so we won't as easily get into a report sending + // loop. + var failure bool + for _, result := range hostResults { + if result.Summary.TotalFailureSessionCount > 0 { + failure = true + break + } + } + if recipientDomainResult.Summary.TotalFailureSessionCount > 0 { + failure = true + } + results := make([]tlsrptdb.TLSResult, 0, 1+len(hostResults)) tlsaPolicyDomains := map[string]bool{} addResult := func(r tlsrpt.Result, isHost bool) { @@ -629,7 +643,7 @@ func deliver(resolver dns.Resolver, m Msg) { DayUTC: dayUTC, RecipientDomain: m.RecipientDomain.Domain.Name(), IsHost: isHost, - SendReport: !m.IsTLSReport, + SendReport: !m.IsTLSReport && (!m.IsDMARCReport || failure), Results: []tlsrpt.Result{r}, } results = append(results, tlsResult) diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 1f28f6a..d5a4baf 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -985,13 +985,12 @@ func TestTLSReport(t *testing.T) { ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/tlsrpt/mox.conf"), resolver) defer ts.close() - run := func(tlsrpt string, n int) { + run := func(rcptTo, tlsrpt string, n int) { t.Helper() ts.run(func(err error, client *smtpclient.Client) { t.Helper() mailFrom := "remote@example.org" - rcptTo := "mjl@mox.example" msgb := &bytes.Buffer{} _, xerr := fmt.Fprintf(msgb, "From: %s\r\nTo: %s\r\nSubject: tlsrpt report\r\nMIME-Version: 1.0\r\nContent-Type: application/tlsrpt+json\r\n\r\n%s\r\n", mailFrom, rcptTo, tlsrpt) @@ -1017,13 +1016,15 @@ func TestTLSReport(t *testing.T) { const tlsrpt = `{"organization-name":"Example.org","date-range":{"start-datetime":"2022-01-07T00:00:00Z","end-datetime":"2022-01-07T23:59:59Z"},"contact-info":"tlsrpt@example.org","report-id":"1","policies":[{"policy":{"policy-type":"no-policy-found","policy-domain":"xmox.nl"},"summary":{"total-successful-session-count":1,"total-failure-session-count":0}}]}` - run(tlsrpt, 0) - run(strings.ReplaceAll(tlsrpt, "xmox.nl", "mox.example"), 1) + run("mjl@mox.example", tlsrpt, 0) + run("mjl@mox.example", strings.ReplaceAll(tlsrpt, "xmox.nl", "mox.example"), 1) + run("mjl@mailhost.mox.example", strings.ReplaceAll(tlsrpt, "xmox.nl", "mailhost.mox.example"), 2) // We always store as an evaluation, but as optional for reports. - evals := checkEvaluationCount(t, 2) + evals := checkEvaluationCount(t, 3) tcompare(t, evals[0].Optional, true) tcompare(t, evals[1].Optional, true) + tcompare(t, evals[2].Optional, true) } func TestRatelimitConnectionrate(t *testing.T) { diff --git a/testdata/smtp/tlsrpt/mox.conf b/testdata/smtp/tlsrpt/mox.conf index b9062bf..14b4adc 100644 --- a/testdata/smtp/tlsrpt/mox.conf +++ b/testdata/smtp/tlsrpt/mox.conf @@ -1,9 +1,13 @@ DataDir: ../data User: 1000 LogLevel: trace -Hostname: mox.example +Hostname: mailhost.mox.example Postmaster: Account: mjl Mailbox: postmaster Listeners: local: nil +HostTLSRPT: + Account: mjl + Mailbox: TLSRPT + Localpart: mjl diff --git a/tlsrptdb/db.go b/tlsrptdb/db.go index a803657..2d1f393 100644 --- a/tlsrptdb/db.go +++ b/tlsrptdb/db.go @@ -17,7 +17,7 @@ var ( mutex sync.Mutex // Accessed directly by tlsrptsend. - ResultDBTypes = []any{TLSResult{}} + ResultDBTypes = []any{TLSResult{}, TLSRPTSuppressAddress{}} ResultDB *bstore.DB ) diff --git a/tlsrptdb/result.go b/tlsrptdb/result.go index 7ea13be..7f2e71f 100644 --- a/tlsrptdb/result.go +++ b/tlsrptdb/result.go @@ -51,6 +51,18 @@ type TLSResult struct { Results []tlsrpt.Result } +// todo: TLSRPTSuppressAddress should be named just SuppressAddress, but would clash with dmarcdb.SuppressAddress in sherpa api. + +// TLSRPTSuppressAddress is a reporting address for which outgoing TLS reports +// will be suppressed for a period. +type TLSRPTSuppressAddress struct { + ID int64 + Inserted time.Time `bstore:"default now"` + ReportingAddress string `bstore:"unique"` + Until time.Time `bstore:"nonzero"` + Comment string +} + func resultDB(ctx context.Context) (rdb *bstore.DB, rerr error) { mutex.Lock() defer mutex.Unlock() @@ -159,3 +171,49 @@ func RemoveResultsPolicyDomain(ctx context.Context, policyDomain dns.Domain, day _, err = bstore.QueryDB[TLSResult](ctx, db).FilterNonzero(TLSResult{PolicyDomain: policyDomain.Name(), DayUTC: dayUTC}).Delete() return err } + +// SuppressAdd adds an address to the suppress list. +func SuppressAdd(ctx context.Context, ba *TLSRPTSuppressAddress) error { + db, err := resultDB(ctx) + if err != nil { + return err + } + + return db.Insert(ctx, ba) +} + +// SuppressList returns all reporting addresses on the suppress list. +func SuppressList(ctx context.Context) ([]TLSRPTSuppressAddress, error) { + db, err := resultDB(ctx) + if err != nil { + return nil, err + } + + return bstore.QueryDB[TLSRPTSuppressAddress](ctx, db).SortDesc("ID").List() +} + +// SuppressRemove removes a reporting address record from the suppress list. +func SuppressRemove(ctx context.Context, id int64) error { + db, err := resultDB(ctx) + if err != nil { + return err + } + + return db.Delete(ctx, &TLSRPTSuppressAddress{ID: id}) +} + +// SuppressUpdate updates the until field of a reporting address record. +func SuppressUpdate(ctx context.Context, id int64, until time.Time) error { + db, err := resultDB(ctx) + if err != nil { + return err + } + + ba := TLSRPTSuppressAddress{ID: id} + err = db.Get(ctx, &ba) + if err != nil { + return err + } + ba.Until = until + return db.Update(ctx, &ba) +} diff --git a/tlsrptsend/send.go b/tlsrptsend/send.go index d6872bb..3db4934 100644 --- a/tlsrptsend/send.go +++ b/tlsrptsend/send.go @@ -441,6 +441,19 @@ Period: %s - %s UTC msgSize := int64(len(msgPrefix)) + msgInfo.Size() for _, rcpt := range recipients { + // If recipient is on suppression list, we won't queue the reporting message. + q := bstore.QueryDB[tlsrptdb.TLSRPTSuppressAddress](ctx, db) + q.FilterNonzero(tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: rcpt.Address.Path().String()}) + q.FilterGreater("Until", time.Now()) + exists, err := q.Exists() + if err != nil { + return false, fmt.Errorf("querying suppress list: %v", err) + } + if exists { + log.Info("suppressing outgoing tls report", mlog.Field("reportingaddress", rcpt.Address)) + continue + } + qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, from.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil) // Don't try as long as regular deliveries, and stop before we would send the // delayed DSN. Though we also won't send that due to IsTLSReport. @@ -451,7 +464,7 @@ Period: %s - %s UTC no := false qm.RequireTLS = &no - err := queueAdd(ctx, log, &qm, msgf) + err = queueAdd(ctx, log, &qm, msgf) if err != nil { tempError = true log.Errorx("queueing message with tls report", err) diff --git a/tlsrptsend/send_test.go b/tlsrptsend/send_test.go index 74289fc..1727623 100644 --- a/tlsrptsend/send_test.go +++ b/tlsrptsend/send_test.go @@ -381,4 +381,14 @@ func TestSendReports(t *testing.T) { "tls-reports3@mailhost.sender.example": report2, } test(tlsResults, expReports) + + db.Insert(ctxbg, + &tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: "tls-reports@sender.example", Until: time.Now().Add(-time.Minute)}, // Expired, so ignored. + &tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: "tls-reports1@mailhost.sender.example", Until: time.Now().Add(time.Minute)}, // Still valid. + &tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: "tls-reports3@mailhost.sender.example", Until: time.Now().Add(31 * 24 * time.Hour)}, // Still valid. + ) + test(tlsResults, map[string]tlsrpt.Report{ + "tls-reports@sender.example": report1, + "tls-reports2@mailhost.sender.example": report2, + }) } diff --git a/webadmin/admin.go b/webadmin/admin.go index 9d4f821..030bea8 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -2031,6 +2031,36 @@ func (Admin) DMARCRemoveEvaluations(ctx context.Context, domain string) { xcheckf(ctx, err, "removing evaluations for domain") } +// DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing +// reports will be suppressed for a period. +func (Admin) DMARCSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) { + addr, err := smtp.ParseAddress(reportingAddress) + xcheckuserf(ctx, err, "parsing reporting address") + + ba := dmarcdb.SuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment} + err = dmarcdb.SuppressAdd(ctx, &ba) + xcheckf(ctx, err, "adding address to suppresslist") +} + +// DMARCSuppressList returns all reporting addresses on the suppress list. +func (Admin) DMARCSuppressList(ctx context.Context) []dmarcdb.SuppressAddress { + l, err := dmarcdb.SuppressList(ctx) + xcheckf(ctx, err, "listing reporting addresses in suppresslist") + return l +} + +// DMARCSuppressRemove removes a reporting address record from the suppress list. +func (Admin) DMARCSuppressRemove(ctx context.Context, id int64) { + err := dmarcdb.SuppressRemove(ctx, id) + xcheckf(ctx, err, "removing reporting address from suppresslist") +} + +// DMARCSuppressExtend updates the until field of a suppressed reporting address record. +func (Admin) DMARCSuppressExtend(ctx context.Context, id int64, until time.Time) { + err := dmarcdb.SuppressUpdate(ctx, id, until) + xcheckf(ctx, err, "updating reporting address in suppresslist") +} + // TLSRPTResults returns all TLSRPT results in the database. func (Admin) TLSRPTResults(ctx context.Context) []tlsrptdb.TLSResult { results, err := tlsrptdb.Results(ctx) @@ -2078,3 +2108,33 @@ func (Admin) TLSRPTRemoveResults(ctx context.Context, domain string, day string) err = tlsrptdb.RemoveResultsPolicyDomain(ctx, dom, day) xcheckf(ctx, err, "removing tls results") } + +// TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing +// reports will be suppressed for a period. +func (Admin) TLSRPTSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) { + addr, err := smtp.ParseAddress(reportingAddress) + xcheckuserf(ctx, err, "parsing reporting address") + + ba := tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment} + err = tlsrptdb.SuppressAdd(ctx, &ba) + xcheckf(ctx, err, "adding address to suppresslist") +} + +// TLSRPTSuppressList returns all reporting addresses on the suppress list. +func (Admin) TLSRPTSuppressList(ctx context.Context) []tlsrptdb.TLSRPTSuppressAddress { + l, err := tlsrptdb.SuppressList(ctx) + xcheckf(ctx, err, "listing reporting addresses in suppresslist") + return l +} + +// TLSRPTSuppressRemove removes a reporting address record from the suppress list. +func (Admin) TLSRPTSuppressRemove(ctx context.Context, id int64) { + err := tlsrptdb.SuppressRemove(ctx, id) + xcheckf(ctx, err, "removing reporting address from suppresslist") +} + +// TLSRPTSuppressExtend updates the until field of a suppressed reporting address record. +func (Admin) TLSRPTSuppressExtend(ctx context.Context, id int64, until time.Time) { + err := tlsrptdb.SuppressUpdate(ctx, id, until) + xcheckf(ctx, err, "updating reporting address in suppresslist") +} diff --git a/webadmin/admin.html b/webadmin/admin.html index bfaa38c..69aebc7 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -1023,7 +1023,7 @@ const dmarcIndex = async () => { dom._kids(page, crumbs( crumblink('Mox Admin', '#'), - 'DMARC reports and evaluations', + 'DMARC', ), dom.ul( dom.li( @@ -1085,7 +1085,10 @@ const renderDMARCSummaries = (summaries) => { } const dmarcEvaluations = async () => { - const evalStats = await api.DMARCEvaluationStats() + const [evalStats, suppressAddresses] = await Promise.all([ + api.DMARCEvaluationStats(), + api.DMARCSuppressList(), + ]) const isEmpty = (o) => { for (const e in o) { @@ -1094,6 +1097,9 @@ const dmarcEvaluations = async () => { return true } + let fieldset, reportingAddress, until, comment + const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) + const page = document.getElementById('page') dom._kids(page, crumbs( @@ -1121,6 +1127,101 @@ const dmarcEvaluations = async () => { isEmpty(evalStats) ? dom.tr(dom.td(attr({colspan: '3'}), 'No evaluations.')) : [], ), ), + dom.br(), + dom.br(), + dom.h2('Suppressed reporting addresses'), + dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a supression list for a period, to reduce noise in the postmaster mailbox.'), + dom.form( + async function submit(e) { + e.stopPropagation() + e.preventDefault() + try { + fieldset.disabled = true + await api.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + fieldset.disabled = false + } + window.location.reload() // todo: add the address to the list, or only reload the list + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Reporting address', + dom.br(), + reportingAddress=dom.input(attr({required: ''})), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Until', + dom.br(), + until=dom.input(attr({type: 'date', required: '', value: nextmonth.getFullYear()+'-'+(1+nextmonth.getMonth())+'-'+nextmonth.getDate()})), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + dom.span('Comment (optional)'), + dom.br(), + comment=dom.input(), + ), + ' ', + dom.button('Add', attr({title: 'Outgoing reports to this reporting address will be suppressed until the end time.'})), + ), + ), + dom.br(), + dom('table.hover', + dom.thead( + dom.tr( + dom.th('Reporting address'), + dom.th('Until'), + dom.th('Comment'), + dom.th('Action'), + ), + ), + dom.tbody( + (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr({colspan: '4'}), 'No suppressed reporting addresses.')) : [], + (suppressAddresses || []).map(ba => + dom.tr( + dom.td(ba.ReportingAddress), + dom.td(ba.Until), + dom.td(ba.Comment), + dom.td( + dom.button('Remove', attr({type: 'button'}), async function click(e) { + try { + e.target.disabled = true + await api.DMARCSuppressRemove(ba.ID) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + e.target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ' ', + dom.button('Extend for 1 month', attr({type: 'button'}), async function click(e) { + try { + e.target.disabled = true + await api.DMARCSuppressExtend(ba.ID, new Date(new Date().getTime() + 31*24*3600*1000)) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + e.target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ), + ) + ), + ), + ), ) } @@ -1479,10 +1580,16 @@ const tlsrptIndex = async () => { } const tlsrptResults = async () => { - const results = await api.TLSRPTResults() + const [results, suppressAddresses] = await Promise.all([ + api.TLSRPTResults(), + api.TLSRPTSuppressList(), + ]) // todo: add a view where results are grouped by policy domain+dayutc. now each recipient domain gets a row. + let fieldset, reportingAddress, until, comment + const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) + const page = document.getElementById('page') dom._kids(page, crumbs( @@ -1545,6 +1652,101 @@ const tlsrptResults = async () => { results.length === 0 ? dom.tr(dom.td(attr({colspan: '9'}), 'No results.')) : [], ), ), + dom.br(), + dom.br(), + dom.h2('Suppressed reporting addresses'), + dom.p('In practice, sending a TLS report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppress list for a period, to reduce noise in the postmaster mailbox.'), + dom.form( + async function submit(e) { + e.stopPropagation() + e.preventDefault() + try { + fieldset.disabled = true + await api.TLSRPTSuppressAdd(reportingAddress.value, new Date(until.value), comment.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + fieldset.disabled = false + } + window.location.reload() // todo: add the address to the list, or only reload the list + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Reporting address', + dom.br(), + reportingAddress=dom.input(attr({required: ''})), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Until', + dom.br(), + until=dom.input(attr({type: 'date', required: '', value: nextmonth.getFullYear()+'-'+(1+nextmonth.getMonth())+'-'+nextmonth.getDate()})), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + dom.span('Comment (optional)'), + dom.br(), + comment=dom.input(), + ), + ' ', + dom.button('Add', attr({title: 'Outgoing reports to this reporting address will be suppressed until the end time.'})), + ), + ), + dom.br(), + dom('table.hover', + dom.thead( + dom.tr( + dom.th('Reporting address'), + dom.th('Until'), + dom.th('Comment'), + dom.th('Action'), + ), + ), + dom.tbody( + (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr({colspan: '4'}), 'No suppressed reporting addresses.')) : [], + (suppressAddresses || []).map(ba => + dom.tr( + dom.td(ba.ReportingAddress), + dom.td(ba.Until), + dom.td(ba.Comment), + dom.td( + dom.button('Remove', attr({type: 'button'}), async function click(e) { + try { + e.target.disabled = true + await api.TLSRPTSuppressRemove(ba.ID) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + e.target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ' ', + dom.button('Extend for 1 month', attr({type: 'button'}), async function click(e) { + try { + e.target.disabled = true + await api.TLSRPTSuppressExtend(ba.ID, new Date(new Date().getTime() + 31*24*3600*1000)) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + e.target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ), + ) + ), + ), + ), ) } diff --git a/webadmin/adminapi.json b/webadmin/adminapi.json index fd8c8ce..f1d2a74 100644 --- a/webadmin/adminapi.json +++ b/webadmin/adminapi.json @@ -828,6 +828,77 @@ ], "Returns": [] }, + { + "Name": "DMARCSuppressAdd", + "Docs": "DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing\nreports will be suppressed for a period.", + "Params": [ + { + "Name": "reportingAddress", + "Typewords": [ + "string" + ] + }, + { + "Name": "until", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "comment", + "Typewords": [ + "string" + ] + } + ], + "Returns": [] + }, + { + "Name": "DMARCSuppressList", + "Docs": "DMARCSuppressList returns all reporting addresses on the suppress list.", + "Params": [], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "SuppressAddress" + ] + } + ] + }, + { + "Name": "DMARCSuppressRemove", + "Docs": "DMARCSuppressRemove removes a reporting address record from the suppress list.", + "Params": [ + { + "Name": "id", + "Typewords": [ + "int64" + ] + } + ], + "Returns": [] + }, + { + "Name": "DMARCSuppressExtend", + "Docs": "DMARCSuppressExtend updates the until field of a suppressed reporting address record.", + "Params": [ + { + "Name": "id", + "Typewords": [ + "int64" + ] + }, + { + "Name": "until", + "Typewords": [ + "timestamp" + ] + } + ], + "Returns": [] + }, { "Name": "TLSRPTResults", "Docs": "TLSRPTResults returns all TLSRPT results in the database.", @@ -920,6 +991,77 @@ } ], "Returns": [] + }, + { + "Name": "TLSRPTSuppressAdd", + "Docs": "TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing\nreports will be suppressed for a period.", + "Params": [ + { + "Name": "reportingAddress", + "Typewords": [ + "string" + ] + }, + { + "Name": "until", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "comment", + "Typewords": [ + "string" + ] + } + ], + "Returns": [] + }, + { + "Name": "TLSRPTSuppressList", + "Docs": "TLSRPTSuppressList returns all reporting addresses on the suppress list.", + "Params": [], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "TLSRPTSuppressAddress" + ] + } + ] + }, + { + "Name": "TLSRPTSuppressRemove", + "Docs": "TLSRPTSuppressRemove removes a reporting address record from the suppress list.", + "Params": [ + { + "Name": "id", + "Typewords": [ + "int64" + ] + } + ], + "Returns": [] + }, + { + "Name": "TLSRPTSuppressExtend", + "Docs": "TLSRPTSuppressExtend updates the until field of a suppressed reporting address record.", + "Params": [ + { + "Name": "id", + "Typewords": [ + "int64" + ] + }, + { + "Name": "until", + "Typewords": [ + "timestamp" + ] + } + ], + "Returns": [] } ], "Sections": [], @@ -3808,6 +3950,47 @@ } ] }, + { + "Name": "SuppressAddress", + "Docs": "SuppressAddress is a reporting address for which outgoing DMARC reports\nwill be suppressed for a period.", + "Fields": [ + { + "Name": "ID", + "Docs": "", + "Typewords": [ + "int64" + ] + }, + { + "Name": "Inserted", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "ReportingAddress", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Until", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Comment", + "Docs": "", + "Typewords": [ + "string" + ] + } + ] + }, { "Name": "TLSResult", "Docs": "TLSResult is stored in the database to track TLS results per policy domain, day\nand recipient domain. These records will be included in TLS reports.", @@ -3877,6 +4060,47 @@ ] } ] + }, + { + "Name": "TLSRPTSuppressAddress", + "Docs": "TLSRPTSuppressAddress is a reporting address for which outgoing TLS reports\nwill be suppressed for a period.", + "Fields": [ + { + "Name": "ID", + "Docs": "", + "Typewords": [ + "int64" + ] + }, + { + "Name": "Inserted", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "ReportingAddress", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Until", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Comment", + "Docs": "", + "Typewords": [ + "string" + ] + } + ] } ], "Ints": [],