1package queue
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "strings"
9
10 "github.com/mjl-/bstore"
11
12 "github.com/mjl-/mox/mlog"
13 "github.com/mjl-/mox/smtp"
14 "github.com/mjl-/mox/webapi"
15)
16
17// todo: we should be processing spam complaints and add addresses to the list.
18
19var errSuppressed = errors.New("address is on suppression list")
20
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}
28}
29
30// SuppressionList returns suppression. If account is not empty, only suppression
31// for that account are returned.
32//
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)
36 if account != "" {
37 q.FilterNonzero(webapi.Suppression{Account: account})
38 }
39 return q.List()
40}
41
42// SuppressionLookup looks up a suppression for an address for an account. Returns
43// a nil suppression if not found.
44//
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})
50 sup, err := q.Get()
51 if err == bstore.ErrAbsent {
52 return nil, nil
53 }
54 return &sup, err
55}
56
57// SuppressionAdd adds a suppression for an address for an account, setting
58// BaseAddress based on OriginalAddress.
59//
60// If the base address of original address is already present, an error is
61// returned (such as from bstore).
62//
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)
68}
69
70// SuppressionRemove removes a suppression. The base address for the the given
71// address is removed.
72//
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})
78 n, err := q.Delete()
79 if err != nil {
80 return err
81 }
82 if n == 0 {
83 return bstore.ErrAbsent
84 }
85 return nil
86}
87
88type suppressionCheck struct {
89 MsgID int64
90 Account string
91 Recipient smtp.Path
92 Code int
93 Secode string
94 Source string
95}
96
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()
103 if err != nil {
104 return nil, fmt.Errorf("checking if address is in suppression list: %v", err)
105 } else if exists {
106 xlog.Debug("address already in suppression list")
107 continue
108 }
109
110 origAddr := sc.Recipient.XString(true)
111 sup := webapi.Suppression{
112 Account: sc.Account,
113 BaseAddress: baseAddr,
114 OriginalAddress: origAddr,
115 }
116
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)
119 } else {
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")
126 q.Limit(2)
127 l, err := q.List()
128 if err != nil {
129 xlog.Errorx("checking for previous delivery failures", err)
130 continue
131 }
132 if len(l) < 2 || l[0].Success || l[1].Success {
133 continue
134 }
135 sup.Reason = fmt.Sprintf("delivery failure from %s and three consecutive failures", sc.Source)
136 }
137 if err := tx.Insert(&sup); err != nil {
138 return nil, fmt.Errorf("inserting suppression: %v", err)
139 }
140 suppressedMsgIDs = append(suppressedMsgIDs, sc.MsgID)
141 }
142 return suppressedMsgIDs, nil
143}
144
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 {
149 switch code {
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.
155 return true
156 }
157 if code/100 != 5 {
158 return false
159 }
160 switch secode {
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.
167 return true
168 }
169 return false
170}
171