10	"github.com/mjl-/bstore"
 
12	"github.com/mjl-/mox/mlog"
 
13	"github.com/mjl-/mox/smtp"
 
14	"github.com/mjl-/mox/webapi"
 
17// todo: we should be processing spam complaints and add addresses to the list.
 
19var errSuppressed = errors.New("address is on suppression list")
 
21func baseAddress(a smtp.Path) smtp.Path {
 
22	s := string(a.Localpart)
 
23	s, _, _ = strings.Cut(s, "+")
 
24	s, _, _ = strings.Cut(s, "-")
 
25	s = strings.ReplaceAll(s, ".", "")
 
26	s = strings.ToLower(s)
 
27	return smtp.Path{Localpart: smtp.Localpart(s), IPDomain: a.IPDomain}
 
30// SuppressionList returns suppression. If account is not empty, only suppression
 
31// for that account are returned.
 
33// SuppressionList does not check if an account exists.
 
34func SuppressionList(ctx context.Context, account string) ([]webapi.Suppression, error) {
 
35	q := bstore.QueryDB[webapi.Suppression](ctx, DB)
 
37		q.FilterNonzero(webapi.Suppression{Account: account})
 
42// SuppressionLookup looks up a suppression for an address for an account. Returns
 
43// a nil suppression if not found.
 
45// SuppressionLookup does not check if an account exists.
 
46func SuppressionLookup(ctx context.Context, account string, address smtp.Path) (*webapi.Suppression, error) {
 
47	baseAddr := baseAddress(address).XString(true)
 
48	q := bstore.QueryDB[webapi.Suppression](ctx, DB)
 
49	q.FilterNonzero(webapi.Suppression{Account: account, BaseAddress: baseAddr})
 
51	if err == bstore.ErrAbsent {
 
57// SuppressionAdd adds a suppression for an address for an account, setting
 
58// BaseAddress based on OriginalAddress.
 
60// If the base address of original address is already present, an error is
 
61// returned (such as from bstore).
 
63// SuppressionAdd does not check if an account exists.
 
64func SuppressionAdd(ctx context.Context, originalAddress smtp.Path, sup *webapi.Suppression) error {
 
65	sup.BaseAddress = baseAddress(originalAddress).XString(true)
 
66	sup.OriginalAddress = originalAddress.XString(true)
 
67	return DB.Insert(ctx, sup)
 
70// SuppressionRemove removes a suppression. The base address for the the given
 
73// SuppressionRemove does not check if an account exists.
 
74func SuppressionRemove(ctx context.Context, account string, address smtp.Path) error {
 
75	baseAddr := baseAddress(address).XString(true)
 
76	q := bstore.QueryDB[webapi.Suppression](ctx, DB)
 
77	q.FilterNonzero(webapi.Suppression{Account: account, BaseAddress: baseAddr})
 
83		return bstore.ErrAbsent
 
88type suppressionCheck struct {
 
97// process failures, possibly creating suppressions.
 
98func suppressionProcess(log mlog.Log, tx *bstore.Tx, scl ...suppressionCheck) (suppressedMsgIDs []int64, err error) {
 
99	for _, sc := range scl {
 
100		xlog := log.With(slog.Any("suppressioncheck", sc))
 
101		baseAddr := baseAddress(sc.Recipient).XString(true)
 
102		exists, err := bstore.QueryTx[webapi.Suppression](tx).FilterNonzero(webapi.Suppression{Account: sc.Account, BaseAddress: baseAddr}).Exists()
 
104			return nil, fmt.Errorf("checking if address is in suppression list: %v", err)
 
106			xlog.Debug("address already in suppression list")
 
110		origAddr := sc.Recipient.XString(true)
 
111		sup := webapi.Suppression{
 
113			BaseAddress:     baseAddr,
 
114			OriginalAddress: origAddr,
 
117		if isImmedateBlock(sc.Code, sc.Secode) {
 
118			sup.Reason = fmt.Sprintf("delivery failure from %s with smtp code %d, enhanced code %q", sc.Source, sc.Code, sc.Secode)
 
120			// If two most recent deliveries failed (excluding this one, so three most recent
 
121			// messages including this one), we'll add the address to the list.
 
122			q := bstore.QueryTx[MsgRetired](tx)
 
123			q.FilterNonzero(MsgRetired{RecipientAddress: origAddr})
 
124			q.FilterNotEqual("ID", sc.MsgID)
 
125			q.SortDesc("LastActivity")
 
129				xlog.Errorx("checking for previous delivery failures", err)
 
132			if len(l) < 2 || l[0].Success || l[1].Success {
 
135			sup.Reason = fmt.Sprintf("delivery failure from %s and three consecutive failures", sc.Source)
 
137		if err := tx.Insert(&sup); err != nil {
 
138			return nil, fmt.Errorf("inserting suppression: %v", err)
 
140		suppressedMsgIDs = append(suppressedMsgIDs, sc.MsgID)
 
142	return suppressedMsgIDs, nil
 
145// Decide whether an SMTP code and short enhanced code is a reason for an
 
146// immediate suppression listing. For some errors, we don't want to bother the
 
147// remote mail server again, or they may decide our behaviour looks spammy.
 
148func isImmedateBlock(code int, secode string) bool {
 
150	case smtp.C521HostNoMail, // Host is not interested in accepting email at all.
 
151		smtp.C550MailboxUnavail, // Likely mailbox does not exist.
 
152		smtp.C551UserNotLocal,   // Also not interested in accepting email for this address.
 
153		smtp.C553BadMailbox,     // We are sending a mailbox name that server doesn't understand and won't accept email for.
 
154		smtp.C556DomainNoMail:   // Remote is not going to accept email for this address/domain.
 
161	case smtp.SeAddr1UnknownDestMailbox1, // Recipient localpart doesn't exist.
 
162		smtp.SeAddr1UnknownSystem2,    // Bad recipient domain.
 
163		smtp.SeAddr1MailboxSyntax3,    // Remote doesn't understand syntax.
 
164		smtp.SeAddr1DestMailboxMoved6, // Address no longer exists.
 
165		smtp.SeMailbox2Disabled1,      // Account exists at remote, but is disabled.
 
166		smtp.SePol7DeliveryUnauth1:    // Seems popular for saying we are on a blocklist.