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