1package webmail
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "runtime/debug"
11 "slices"
12 "testing"
13
14 "github.com/mjl-/bstore"
15 "github.com/mjl-/sherpa"
16
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/queue"
21 "github.com/mjl-/mox/store"
22)
23
24func tneedErrorCode(t *testing.T, code string, fn func()) {
25 t.Helper()
26 defer func() {
27 t.Helper()
28 x := recover()
29 if x == nil {
30 debug.PrintStack()
31 t.Fatalf("expected sherpa user error, saw success")
32 }
33 if err, ok := x.(*sherpa.Error); !ok {
34 debug.PrintStack()
35 t.Fatalf("expected sherpa error, saw %#v", x)
36 } else if err.Code != code {
37 debug.PrintStack()
38 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
39 }
40 }()
41
42 fn()
43}
44
45func tneedError(t *testing.T, fn func()) {
46 tneedErrorCode(t, "user:error", fn)
47}
48
49// Test API calls.
50// todo: test that the actions make the changes they claim to make. we currently just call the functions and have only limited checks that state changed.
51func TestAPI(t *testing.T) {
52 mox.LimitersInit()
53 os.RemoveAll("../testdata/webmail/data")
54 mox.Context = ctxbg
55 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
56 mox.ConfigDynamicPath = filepath.FromSlash("../testdata/webmail/domains.conf")
57 mox.MustLoadConfig(true, false)
58 defer store.Switchboard()()
59
60 log := mlog.New("webmail", nil)
61 acc, err := store.OpenAccount(log, "mjl")
62 tcheck(t, err, "open account")
63 const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
64 const pw1 = "tést " // PRECIS normalized, with NFC.
65 err = acc.SetPassword(log, pw0)
66 tcheck(t, err, "set password")
67 defer func() {
68 err := acc.Close()
69 pkglog.Check(err, "closing account")
70 acc.CheckClosed()
71 }()
72
73 var zerom store.Message
74 var (
75 inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
76 inboxText = &testmsg{"Inbox", store.Flags{}, nil, msgText, zerom, 0}
77 inboxHTML = &testmsg{"Inbox", store.Flags{}, nil, msgHTML, zerom, 0}
78 inboxAlt = &testmsg{"Inbox", store.Flags{}, nil, msgAlt, zerom, 0}
79 inboxAltRel = &testmsg{"Inbox", store.Flags{}, nil, msgAltRel, zerom, 0}
80 inboxAttachments = &testmsg{"Inbox", store.Flags{}, nil, msgAttachments, zerom, 0}
81 testbox1Alt = &testmsg{"Testbox1", store.Flags{}, nil, msgAlt, zerom, 0}
82 rejectsMinimal = &testmsg{"Rejects", store.Flags{Junk: true}, nil, msgMinimal, zerom, 0}
83 )
84 var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal}
85
86 for _, tm := range testmsgs {
87 tdeliver(t, acc, tm)
88 }
89
90 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
91
92 // Test login, and rate limiter.
93 loginReqInfo := requestInfo{log, "mjl@mox.example", nil, "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
94 loginctx := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo)
95
96 // Missing login token.
97 tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", pw0) })
98
99 // Login with loginToken.
100 loginCookie := &http.Cookie{Name: "webmaillogin"}
101 loginCookie.Value = api.LoginPrep(loginctx)
102 loginReqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
103
104 testLogin := func(username, password string, expErrCodes ...string) {
105 t.Helper()
106
107 defer func() {
108 x := recover()
109 expErr := len(expErrCodes) > 0
110 if (x != nil) != expErr {
111 t.Fatalf("got %v, expected codes %v, for username %q, password %q", x, expErrCodes, username, password)
112 }
113 if x == nil {
114 return
115 } else if err, ok := x.(*sherpa.Error); !ok {
116 t.Fatalf("got %#v, expected at most *sherpa.Error", x)
117 } else if !slices.Contains(expErrCodes, err.Code) {
118 t.Fatalf("got error code %q, expected %v", err.Code, expErrCodes)
119 }
120 }()
121
122 api.Login(loginctx, loginCookie.Value, username, password)
123 }
124 testLogin("mjl@mox.example", pw0)
125 testLogin("mjl@mox.example", pw1)
126 testLogin("móx@mox.example", pw1) // NFC username
127 testLogin("mo\u0301x@mox.example", pw1) // NFD username
128 testLogin("mjl@mox.example", pw1+" ", "user:loginFailed")
129 testLogin("nouser@mox.example", pw0, "user:loginFailed")
130 testLogin("nouser@bad.example", pw0, "user:loginFailed")
131 for i := 3; i < 10; i++ {
132 testLogin("bad@bad.example", pw0, "user:loginFailed")
133 }
134 // Ensure rate limiter is triggered, also for slow tests.
135 for i := 0; i < 10; i++ {
136 testLogin("bad@bad.example", pw0, "user:loginFailed", "user:error")
137 }
138 testLogin("bad@bad.example", pw0, "user:error")
139
140 // Context with different IP, for clear rate limit history.
141 reqInfo := requestInfo{log, "mjl@mox.example", acc, "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
142 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
143
144 // FlagsAdd
145 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
146 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
147 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
148 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`})
149 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`}) // No change.
150 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{}) // Nothing to do.
151 api.FlagsAdd(ctx, []int64{}, []string{}) // No messages, no flags.
152 api.FlagsAdd(ctx, []int64{}, []string{`custom`}) // No message, new flag.
153 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$junk`}) // Trigger retrain.
154 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$notjunk`}) // Trigger retrain.
155 api.FlagsAdd(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`}) // Trigger retrain, messages in different mailboxes.
156 api.FlagsAdd(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`, `newlabel`}) // Two mailboxes with counts and keywords changed.
157 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
158 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{``}) }) // Empty is invalid.
159 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) }) // Only predefined system flags.
160
161 // FlagsClear, inverse of FlagsAdd.
162 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
163 api.FlagsClear(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
164 api.FlagsClear(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
165 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
166 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
167 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{})
168 api.FlagsClear(ctx, []int64{}, []string{})
169 api.FlagsClear(ctx, []int64{}, []string{`custom`})
170 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$junk`})
171 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$notjunk`})
172 api.FlagsClear(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`})
173 api.FlagsClear(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`}) // Two mailboxes with counts changed.
174 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
175 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{``}) })
176 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
177
178 // MailboxSetSpecialUse
179 var inbox, archive, sent, drafts, testbox1 store.Mailbox
180 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
181 get := func(k string, v any) store.Mailbox {
182 mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get()
183 tcheck(t, err, "get special-use mailbox")
184 return mb
185 }
186 drafts = get("Draft", true)
187 sent = get("Sent", true)
188 archive = get("Archive", true)
189 get("Trash", true)
190 get("Junk", true)
191
192 inbox = get("Name", "Inbox")
193 testbox1 = get("Name", "Testbox1")
194 return nil
195 })
196 tcheck(t, err, "get mailboxes")
197 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: archive.ID, SpecialUse: store.SpecialUse{Draft: true}}) // Already set.
198 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true}}) // New draft mailbox.
199 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Sent: true}})
200 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Archive: true}})
201 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Trash: true}})
202 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Junk: true}})
203 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None
204 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true, Sent: true, Archive: true, Trash: true, Junk: true}}) // All
205 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None again.
206 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{Sent: true}}) // Sent, for sending mail later.
207 tneedError(t, func() { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: 0}) })
208
209 // MailboxRename
210 api.MailboxRename(ctx, testbox1.ID, "Testbox2")
211 api.MailboxRename(ctx, testbox1.ID, "Test/A/B/Box1")
212 api.MailboxRename(ctx, testbox1.ID, "Test/A/Box1")
213 api.MailboxRename(ctx, testbox1.ID, "Testbox1")
214 tneedError(t, func() { api.MailboxRename(ctx, 0, "BadID") })
215 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Testbox1") }) // Already this name.
216 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Inbox") }) // Inbox not allowed.
217 tneedError(t, func() { api.MailboxRename(ctx, inbox.ID, "Binbox") }) // Inbox not allowed.
218 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Archive") }) // Exists.
219
220 // ParsedMessage
221 // todo: verify contents
222 api.ParsedMessage(ctx, inboxMinimal.ID)
223 api.ParsedMessage(ctx, inboxHTML.ID)
224 api.ParsedMessage(ctx, inboxAlt.ID)
225 api.ParsedMessage(ctx, inboxAltRel.ID)
226 api.ParsedMessage(ctx, testbox1Alt.ID)
227 tneedError(t, func() { api.ParsedMessage(ctx, 0) })
228 tneedError(t, func() { api.ParsedMessage(ctx, testmsgs[len(testmsgs)-1].ID+1) })
229 pm := api.ParsedMessage(ctx, inboxText.ID)
230 tcompare(t, pm.ViewMode, store.ModeDefault)
231
232 api.FromAddressSettingsSave(ctx, store.FromAddressSettings{FromAddress: "mjl@mox.example", ViewMode: store.ModeHTMLExt})
233 pm = api.ParsedMessage(ctx, inboxText.ID)
234 tcompare(t, pm.ViewMode, store.ModeHTMLExt)
235
236 // MailboxDelete
237 api.MailboxDelete(ctx, testbox1.ID)
238 testa, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Test/A").Get()
239 tcheck(t, err, "get mailbox Test/A")
240 tneedError(t, func() { api.MailboxDelete(ctx, testa.ID) }) // Test/A/B still exists.
241 tneedError(t, func() { api.MailboxDelete(ctx, 0) }) // Bad ID.
242 tneedError(t, func() { api.MailboxDelete(ctx, testbox1.ID) }) // No longer exists.
243 tneedError(t, func() { api.MailboxDelete(ctx, inbox.ID) }) // Cannot remove inbox.
244 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
245
246 api.MailboxCreate(ctx, "Testbox1")
247 testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Testbox1").Get()
248 tcheck(t, err, "get testbox1")
249 tdeliver(t, acc, testbox1Alt)
250
251 // MailboxEmpty
252 api.MailboxEmpty(ctx, testbox1.ID)
253 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
254 tneedError(t, func() { api.MailboxEmpty(ctx, 0) }) // Bad ID.
255
256 // MessageMove
257 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, inbox.ID) }) // Message was removed (with MailboxEmpty above).
258 api.MessageMove(ctx, []int64{}, testbox1.ID) // No messages.
259 tdeliver(t, acc, testbox1Alt)
260 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID) }) // Already in destination mailbox.
261 tneedError(t, func() { api.MessageMove(ctx, []int64{}, 0) }) // Bad ID.
262 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}, testbox1.ID)
263 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID, testbox1Alt.ID}, inbox.ID) // From different mailboxes.
264 api.FlagsAdd(ctx, []int64{inboxMinimal.ID}, []string{`minimallabel`}) // For move.
265 api.MessageMove(ctx, []int64{inboxMinimal.ID}, testbox1.ID) // Move causes new label for destination mailbox.
266 api.MessageMove(ctx, []int64{rejectsMinimal.ID}, testbox1.ID) // Move causing readjustment of MailboxOrigID due to Rejects mailbox.
267 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID, inboxMinimal.ID}, testbox1.ID) }) // inboxMinimal already in destination.
268 // Restore.
269 api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID)
270 api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID)
271
272 // MessageDelete
273 api.MessageDelete(ctx, []int64{}) // No messages.
274 api.MessageDelete(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}) // Same mailbox.
275 api.MessageDelete(ctx, []int64{inboxText.ID, testbox1Alt.ID, inboxAltRel.ID}) // Multiple mailboxes, multiple times.
276 tneedError(t, func() { api.MessageDelete(ctx, []int64{0}) }) // Bad ID.
277 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID + 999}) }) // Bad ID
278 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID}) }) // Already removed.
279 tdeliver(t, acc, testbox1Alt)
280 tdeliver(t, acc, inboxAltRel)
281
282 // MessageCompose
283 draftID := api.MessageCompose(ctx, ComposeMessage{
284 From: "mjl@mox.example",
285 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
286 Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
287 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
288 Subject: "test email",
289 TextBody: "this is the content\n\ncheers,\nmox",
290 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
291 }, drafts.ID)
292 // Replace draft.
293 draftID = api.MessageCompose(ctx, ComposeMessage{
294 From: "mjl@mox.example",
295 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
296 Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
297 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
298 Subject: "test email",
299 TextBody: "this is the content\n\ncheers,\nmox",
300 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
301 DraftMessageID: draftID,
302 }, drafts.ID)
303
304 // MessageFindMessageID
305 msgID := api.MessageFindMessageID(ctx, "<absent@localhost>")
306 tcompare(t, msgID, int64(0))
307
308 // MessageSubmit
309 queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
310 err = queue.Init()
311 tcheck(t, err, "queue init")
312 api.MessageSubmit(ctx, SubmitMessage{
313 From: "mjl@mox.example",
314 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
315 Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
316 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
317 Subject: "test email",
318 TextBody: "this is the content\n\ncheers,\nmox",
319 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
320 UserAgent: "moxwebmail/dev",
321 DraftMessageID: draftID,
322 })
323 // todo: check delivery of 6 messages to inbox, 1 to sent
324
325 // Reply with attachments.
326 api.MessageSubmit(ctx, SubmitMessage{
327 From: "mjl@mox.example",
328 To: []string{"mjl+to@mox.example"},
329 Subject: "Re: reply with attachments",
330 TextBody: "sending you these fake png files",
331 Attachments: []File{
332 {
333 Filename: "test1.png",
334 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
335 },
336 {
337 Filename: "test1.png",
338 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
339 },
340 },
341 ResponseMessageID: testbox1Alt.ID,
342 })
343 // todo: check answered flag
344
345 // Forward with attachments.
346 api.MessageSubmit(ctx, SubmitMessage{
347 From: "mjl@mox.example",
348 To: []string{"mjl+to@mox.example"},
349 Subject: "Fwd: the original subject",
350 TextBody: "look what i got",
351 Attachments: []File{
352 {
353 Filename: "test1.png",
354 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
355 },
356 },
357 ForwardAttachments: ForwardAttachments{
358 MessageID: inboxAltRel.ID,
359 Paths: [][]int{{1, 1}, {1, 1}},
360 },
361 IsForward: true,
362 ResponseMessageID: testbox1Alt.ID,
363 })
364 // todo: check forwarded flag, check it has the right attachments.
365
366 // Send from utf8 localpart.
367 api.MessageSubmit(ctx, SubmitMessage{
368 From: "møx@mox.example",
369 To: []string{"mjl+to@mox.example"},
370 TextBody: "test",
371 })
372
373 // Send to utf8 localpart.
374 api.MessageSubmit(ctx, SubmitMessage{
375 From: "mjl@mox.example",
376 To: []string{"møx@mox.example"},
377 TextBody: "test",
378 })
379
380 // Send to utf-8 text.
381 api.MessageSubmit(ctx, SubmitMessage{
382 From: "mjl@mox.example",
383 To: []string{"mjl+to@mox.example"},
384 Subject: "hi ☺",
385 TextBody: fmt.Sprintf("%80s", "tést"),
386 })
387
388 // Send without special-use Sent mailbox.
389 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
390 api.MessageSubmit(ctx, SubmitMessage{
391 From: "mjl@mox.example",
392 To: []string{"mjl+to@mox.example"},
393 Subject: "hi ☺",
394 TextBody: fmt.Sprintf("%80s", "tést"),
395 })
396
397 // Message with From-address of another account.
398 tneedError(t, func() {
399 api.MessageSubmit(ctx, SubmitMessage{
400 From: "other@mox.example",
401 To: []string{"mjl+to@mox.example"},
402 TextBody: "test",
403 })
404 })
405
406 // Message with unknown address.
407 tneedError(t, func() {
408 api.MessageSubmit(ctx, SubmitMessage{
409 From: "doesnotexist@mox.example",
410 To: []string{"mjl+to@mox.example"},
411 TextBody: "test",
412 })
413 })
414
415 // Message without recipient.
416 tneedError(t, func() {
417 api.MessageSubmit(ctx, SubmitMessage{
418 From: "mjl@mox.example",
419 TextBody: "test",
420 })
421 })
422
423 api.maxMessageSize = 1
424 tneedError(t, func() {
425 api.MessageSubmit(ctx, SubmitMessage{
426 From: "mjl@mox.example",
427 To: []string{"mjl+to@mox.example"},
428 Subject: "too large",
429 TextBody: "so many bytes",
430 })
431 })
432 api.maxMessageSize = 1024 * 1024
433
434 // Hit recipient limit.
435 tneedError(t, func() {
436 accConf, _ := acc.Conf()
437 for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
438 api.MessageSubmit(ctx, SubmitMessage{
439 From: fmt.Sprintf("user@mox%d.example", i),
440 TextBody: "test",
441 })
442 }
443 })
444
445 // Hit message limit.
446 tneedError(t, func() {
447 accConf, _ := acc.Conf()
448 for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
449 api.MessageSubmit(ctx, SubmitMessage{
450 From: fmt.Sprintf("user@mox%d.example", i),
451 TextBody: "test",
452 })
453 }
454 })
455
456 l, full := api.CompleteRecipient(ctx, "doesnotexist")
457 tcompare(t, len(l), 0)
458 tcompare(t, full, true)
459 l, full = api.CompleteRecipient(ctx, "cc2")
460 tcompare(t, l, []string{"mjl cc2 <mjl+cc2@mox.example>", "mjl bcc2 <mjl+bcc2@mox.example>"})
461 tcompare(t, full, true)
462
463 // RecipientSecurity
464 resolver := dns.MockResolver{}
465 rs, err := recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
466 tcompare(t, err, nil)
467 tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
468 err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
469 tcheck(t, err, "insert recipient domain tls info")
470 rs, err = recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
471 tcompare(t, err, nil)
472 tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})
473
474 // Suggesting/adding/removing rulesets.
475
476 testSuggest := func(msgID int64, expListID string, expMsgFrom string) {
477 listID, msgFrom, isRemove, rcptTo, ruleset := api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
478 tcompare(t, listID, expListID)
479 tcompare(t, msgFrom, expMsgFrom)
480 tcompare(t, isRemove, false)
481 tcompare(t, rcptTo, "mox@other.example")
482 tcompare(t, ruleset == nil, false)
483
484 // Moving in opposite direction doesn't get a suggestion without the rule present.
485 _, _, _, _, rs0 := api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
486 tcompare(t, rs0 == nil, true)
487
488 api.RulesetAdd(ctx, rcptTo, *ruleset)
489
490 // Ruleset that exists won't get a suggestion again.
491 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
492 tcompare(t, ruleset == nil, true)
493
494 // Moving in oppositive direction, with rule present, gets the suggestion to remove.
495 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
496 tcompare(t, ruleset == nil, false)
497
498 api.RulesetRemove(ctx, rcptTo, *ruleset)
499
500 // If ListID/MsgFrom is marked as never, we won't get a suggestion.
501 api.RulesetMessageNever(ctx, rcptTo, expListID, expMsgFrom, false)
502 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
503 tcompare(t, ruleset == nil, true)
504
505 var n int
506 if expListID != "" {
507 n, err = bstore.QueryDB[store.RulesetNoListID](ctx, acc.DB).Delete()
508 } else {
509 n, err = bstore.QueryDB[store.RulesetNoMsgFrom](ctx, acc.DB).Delete()
510 }
511 tcheck(t, err, "remove never-answer for listid/msgfrom")
512 tcompare(t, n, 1)
513 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
514 tcompare(t, ruleset == nil, false)
515
516 // If Mailbox is marked as never, we won't get a suggestion.
517 api.RulesetMailboxNever(ctx, testbox1.ID, true)
518 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
519 tcompare(t, ruleset == nil, true)
520
521 n, err = bstore.QueryDB[store.RulesetNoMailbox](ctx, acc.DB).Delete()
522 tcheck(t, err, "remove never-answer for mailbox")
523 tcompare(t, n, 1)
524
525 }
526
527 // For MsgFrom.
528 tdeliver(t, acc, inboxText)
529 testSuggest(inboxText.ID, "", "mjl@mox.example")
530
531 // For List-Id.
532 tdeliver(t, acc, inboxHTML)
533 testSuggest(inboxHTML.ID, "list.mox.example", "")
534}
535