14 "github.com/mjl-/bstore"
15 "github.com/mjl-/sherpa"
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"
25func tneedErrorCode(t *testing.T, code string, fn func()) {
32 t.Fatalf("expected sherpa user error, saw success")
34 if err, ok := x.(*sherpa.Error); !ok {
36 t.Fatalf("expected sherpa error, saw %#v", x)
37 } else if err.Code != code {
39 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
46func tneedError(t *testing.T, fn func()) {
47 tneedErrorCode(t, "user:error", fn)
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) {
54 os.RemoveAll("../testdata/webmail/data")
56 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
57 mox.ConfigDynamicPath = filepath.FromSlash("../testdata/webmail/domains.conf")
58 mox.MustLoadConfig(true, false)
59 err := store.Init(ctxbg)
60 tcheck(t, err, "store init")
63 tcheck(t, err, "store close")
65 defer store.Switchboard()()
67 log := mlog.New("webmail", nil)
68 err = mtastsdb.Init(false)
69 tcheck(t, err, "mtastsdb init")
70 acc, err := store.OpenAccount(log, "mjl", false)
71 tcheck(t, err, "open account")
72 const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
73 const pw1 = "tést " // PRECIS normalized, with NFC.
74 err = acc.SetPassword(log, pw0)
75 tcheck(t, err, "set password")
77 err := mtastsdb.Close()
78 tcheck(t, err, "mtastsdb close")
80 pkglog.Check(err, "closing account")
84 var zerom store.Message
86 inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
87 inboxText = &testmsg{"Inbox", store.Flags{}, nil, msgText, zerom, 0}
88 inboxHTML = &testmsg{"Inbox", store.Flags{}, nil, msgHTML, zerom, 0}
89 inboxAlt = &testmsg{"Inbox", store.Flags{}, nil, msgAlt, zerom, 0}
90 inboxAltRel = &testmsg{"Inbox", store.Flags{}, nil, msgAltRel, zerom, 0}
91 inboxAttachments = &testmsg{"Inbox", store.Flags{}, nil, msgAttachments, zerom, 0}
92 testbox1Alt = &testmsg{"Testbox1", store.Flags{}, nil, msgAlt, zerom, 0}
93 rejectsMinimal = &testmsg{"Rejects", store.Flags{Junk: true}, nil, msgMinimal, zerom, 0}
95 var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal}
97 for _, tm := range testmsgs {
101 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
103 // Test login, and rate limiter.
104 loginReqInfo := requestInfo{log, "mjl@mox.example", nil, "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
105 loginctx := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo)
107 // Missing login token.
108 tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", pw0) })
110 // Login with loginToken.
111 loginCookie := &http.Cookie{Name: "webmaillogin"}
112 loginCookie.Value = api.LoginPrep(loginctx)
113 loginReqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
115 testLogin := func(username, password string, expErrCodes ...string) {
120 expErr := len(expErrCodes) > 0
121 if (x != nil) != expErr {
122 panic(fmt.Sprintf("got %v, expected codes %v, for username %q, password %q", x, expErrCodes, username, password))
126 } else if err, ok := x.(*sherpa.Error); !ok {
127 t.Fatalf("got %#v, expected at most *sherpa.Error", x)
128 } else if !slices.Contains(expErrCodes, err.Code) {
129 t.Fatalf("got error code %q, expected %v", err.Code, expErrCodes)
133 api.Login(loginctx, loginCookie.Value, username, password)
135 testLogin("mjl@mox.example", pw0)
136 testLogin("mjl@mox.example", pw1)
137 testLogin("móx@mox.example", pw1) // NFC username
138 testLogin("mo\u0301x@mox.example", pw1) // NFD username
139 testLogin("mjl@mox.example", pw1+" ", "user:loginFailed")
140 testLogin("nouser@mox.example", pw0, "user:loginFailed")
141 testLogin("nouser@bad.example", pw0, "user:loginFailed")
142 for i := 3; i < 10; i++ {
143 testLogin("bad@bad.example", pw0, "user:loginFailed")
145 // Ensure rate limiter is triggered, also for slow tests.
147 testLogin("bad@bad.example", pw0, "user:loginFailed", "user:error")
149 testLogin("bad@bad.example", pw0, "user:error")
151 acc2, err := store.OpenAccount(log, "disabled", false)
152 tcheck(t, err, "open account")
153 err = acc2.SetPassword(log, "test1234")
154 tcheck(t, err, "set password")
156 tcheck(t, err, "close account")
159 loginReqInfo2 := requestInfo{log, "disabled@mox.example", nil, "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
160 loginctx2 := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo2)
161 loginCookie2 := &http.Cookie{Name: "webmaillogin"}
162 loginCookie2.Value = api.LoginPrep(loginctx2)
163 loginReqInfo2.Request.Header = http.Header{"Cookie": []string{loginCookie2.String()}}
164 tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "test1234") })
165 tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "bogus") })
167 // Context with different IP, for clear rate limit history.
168 reqInfo := requestInfo{log, "mjl@mox.example", acc, "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
169 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
172 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
173 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
174 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
175 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`})
176 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`}) // No change.
177 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{}) // Nothing to do.
178 api.FlagsAdd(ctx, []int64{}, []string{}) // No messages, no flags.
179 api.FlagsAdd(ctx, []int64{}, []string{`custom`}) // No message, new flag.
180 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$junk`}) // Trigger retrain.
181 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$notjunk`}) // Trigger retrain.
182 api.FlagsAdd(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`}) // Trigger retrain, messages in different mailboxes.
183 api.FlagsAdd(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`, `newlabel`}) // Two mailboxes with counts and keywords changed.
184 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
185 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{``}) }) // Empty is invalid.
186 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) }) // Only predefined system flags.
188 // FlagsClear, inverse of FlagsAdd.
189 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
190 api.FlagsClear(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
191 api.FlagsClear(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
192 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
193 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
194 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{})
195 api.FlagsClear(ctx, []int64{}, []string{})
196 api.FlagsClear(ctx, []int64{}, []string{`custom`})
197 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$junk`})
198 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$notjunk`})
199 api.FlagsClear(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`})
200 api.FlagsClear(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`}) // Two mailboxes with counts changed.
201 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
202 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{``}) })
203 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
205 // MailboxSetSpecialUse
206 var inbox, archive, sent, drafts, testbox1 store.Mailbox
207 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
208 get := func(k string, v any) store.Mailbox {
209 mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual(k, v).Get()
210 tcheck(t, err, "get special-use mailbox")
213 drafts = get("Draft", true)
214 sent = get("Sent", true)
215 archive = get("Archive", true)
219 inbox = get("Name", "Inbox")
220 testbox1 = get("Name", "Testbox1")
223 tcheck(t, err, "get mailboxes")
224 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: archive.ID, SpecialUse: store.SpecialUse{Draft: true}}) // Already set.
225 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true}}) // New draft mailbox.
226 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Sent: true}})
227 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Archive: true}})
228 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Trash: true}})
229 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Junk: true}})
230 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None
231 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true, Sent: true, Archive: true, Trash: true, Junk: true}}) // All
232 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None again.
233 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{Sent: true}}) // Sent, for sending mail later.
234 tneedError(t, func() { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: 0}) })
237 api.FlagsClear(ctx, []int64{inboxText.ID, inboxMinimal.ID}, []string{`\seen`})
238 api.MailboxesMarkRead(ctx, []int64{inbox.ID, archive.ID, sent.ID})
239 tneedError(t, func() { api.MailboxesMarkRead(ctx, []int64{inbox.ID + 999}) }) // Does not exist.
242 api.MailboxRename(ctx, testbox1.ID, "Testbox2")
243 api.MailboxRename(ctx, testbox1.ID, "Test/A/B/Box1")
244 api.MailboxRename(ctx, testbox1.ID, "Test/A/Box1")
245 api.MailboxRename(ctx, testbox1.ID, "Testbox1")
246 tneedError(t, func() { api.MailboxRename(ctx, 0, "BadID") })
247 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Testbox1") }) // Already this name.
248 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Inbox") }) // Inbox not allowed.
249 tneedError(t, func() { api.MailboxRename(ctx, inbox.ID, "Binbox") }) // Inbox not allowed.
250 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Archive") }) // Exists.
253 // todo: verify contents
254 api.ParsedMessage(ctx, inboxMinimal.ID)
255 api.ParsedMessage(ctx, inboxHTML.ID)
256 api.ParsedMessage(ctx, inboxAlt.ID)
257 api.ParsedMessage(ctx, inboxAltRel.ID)
258 api.ParsedMessage(ctx, testbox1Alt.ID)
259 tneedError(t, func() { api.ParsedMessage(ctx, 0) })
260 tneedError(t, func() { api.ParsedMessage(ctx, testmsgs[len(testmsgs)-1].ID+1) })
261 pm := api.ParsedMessage(ctx, inboxText.ID)
262 tcompare(t, pm.ViewMode, store.ModeText)
264 api.FromAddressSettingsSave(ctx, store.FromAddressSettings{FromAddress: "mjl@mox.example", ViewMode: store.ModeHTMLExt})
265 pm = api.ParsedMessage(ctx, inboxText.ID)
266 tcompare(t, pm.ViewMode, store.ModeHTMLExt)
269 api.MailboxDelete(ctx, testbox1.ID)
270 testa, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Test/A").Get()
271 tcheck(t, err, "get mailbox Test/A")
272 tneedError(t, func() { api.MailboxDelete(ctx, testa.ID) }) // Test/A/B still exists.
273 tneedError(t, func() { api.MailboxDelete(ctx, 0) }) // Bad ID.
274 tneedError(t, func() { api.MailboxDelete(ctx, testbox1.ID) }) // No longer exists.
275 tneedError(t, func() { api.MailboxDelete(ctx, inbox.ID) }) // Cannot remove inbox.
276 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
278 api.MailboxCreate(ctx, "Testbox1")
279 testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Expunged", false).FilterEqual("Name", "Testbox1").Get()
280 tcheck(t, err, "get testbox1")
281 tdeliver(t, acc, testbox1Alt)
284 api.MailboxEmpty(ctx, testbox1.ID)
285 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
286 tneedError(t, func() { api.MailboxEmpty(ctx, 0) }) // Bad ID.
289 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, inbox.ID) }) // Message was removed (with MailboxEmpty above).
290 api.MessageMove(ctx, []int64{}, testbox1.ID) // No messages.
291 tdeliver(t, acc, testbox1Alt)
292 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID) }) // Already in destination mailbox.
293 tneedError(t, func() { api.MessageMove(ctx, []int64{}, 0) }) // Bad ID.
294 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}, testbox1.ID)
295 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID, testbox1Alt.ID}, inbox.ID) // From different mailboxes.
296 api.FlagsAdd(ctx, []int64{inboxMinimal.ID}, []string{`minimallabel`}) // For move.
297 api.MessageMove(ctx, []int64{inboxMinimal.ID}, testbox1.ID) // Move causes new label for destination mailbox.
298 api.MessageMove(ctx, []int64{rejectsMinimal.ID}, testbox1.ID) // Move causing readjustment of MailboxOrigID due to Rejects mailbox.
299 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID, inboxMinimal.ID}, testbox1.ID) }) // inboxMinimal already in destination.
301 api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID)
302 api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID)
305 api.MessageDelete(ctx, []int64{}) // No messages.
306 api.MessageDelete(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}) // Same mailbox.
307 api.MessageDelete(ctx, []int64{inboxText.ID, testbox1Alt.ID, inboxAltRel.ID}) // Multiple mailboxes, multiple times.
308 tneedError(t, func() { api.MessageDelete(ctx, []int64{0}) }) // Bad ID.
309 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID + 999}) }) // Bad ID
310 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID}) }) // Already removed.
311 tdeliver(t, acc, testbox1Alt)
312 tdeliver(t, acc, inboxAltRel)
315 draftID := api.MessageCompose(ctx, ComposeMessage{
316 From: "mjl@mox.example",
317 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
318 Cc: []string{"mjl+cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
319 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
320 Subject: "test email",
321 TextBody: "this is the content\n\ncheers,\nmox",
322 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
325 draftID = api.MessageCompose(ctx, ComposeMessage{
326 From: "mjl@mox.example",
327 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
328 Cc: []string{"mjl+cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
329 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
330 Subject: "test email",
331 TextBody: "this is the content\n\ncheers,\nmox",
332 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
333 DraftMessageID: draftID,
336 // MessageFindMessageID
337 msgID := api.MessageFindMessageID(ctx, "<absent@localhost>")
338 tcompare(t, msgID, int64(0))
341 queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
343 tcheck(t, err, "queue init")
344 defer queue.Shutdown()
345 api.MessageSubmit(ctx, SubmitMessage{
346 From: "mjl@mox.example",
347 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
348 Cc: []string{"mjl+cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
349 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
350 Subject: "test email",
351 TextBody: "this is the content\n\ncheers,\nmox",
352 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
353 UserAgent: "moxwebmail/dev",
354 DraftMessageID: draftID,
356 // todo: check delivery of 6 messages to inbox, 1 to sent
358 api.MessageSubmit(ctx, SubmitMessage{
359 From: "mjl-altcatchall@mox.example",
360 To: []string{"mjl-to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
361 Cc: []string{"mjl-cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
362 Bcc: []string{"mjl-bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
363 Subject: "test email",
364 TextBody: "this is the content\n\ncheers,\nmox",
365 ReplyTo: "mjl replyto <mjl-replyto@mox.example>",
366 UserAgent: "moxwebmail/dev",
368 // todo: check delivery of 6 messages to inbox, 1 to sent
370 // Reply with attachments.
371 api.MessageSubmit(ctx, SubmitMessage{
372 From: "mjl@mox.example",
373 To: []string{"mjl+to@mox.example"},
374 Subject: "Re: reply with attachments",
375 TextBody: "sending you these fake png files",
378 Filename: "test1.png",
379 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
382 Filename: "test1.png",
383 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
386 ResponseMessageID: testbox1Alt.ID,
388 // todo: check answered flag
390 // Forward with attachments.
391 api.MessageSubmit(ctx, SubmitMessage{
392 From: "mjl@mox.example",
393 To: []string{"mjl+to@mox.example"},
394 Subject: "Fwd: the original subject",
395 TextBody: "look what i got",
398 Filename: "test1.png",
399 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
402 ForwardAttachments: ForwardAttachments{
403 MessageID: inboxAltRel.ID,
404 Paths: [][]int{{1, 1}, {1, 1}},
407 ResponseMessageID: testbox1Alt.ID,
409 // todo: check forwarded flag, check it has the right attachments.
411 // Send from utf8 localpart.
412 api.MessageSubmit(ctx, SubmitMessage{
413 From: "møx@mox.example",
414 To: []string{"mjl+to@mox.example"},
418 // Send to utf8 localpart.
419 api.MessageSubmit(ctx, SubmitMessage{
420 From: "mjl@mox.example",
421 To: []string{"møx@mox.example"},
425 // Send to utf-8 text.
426 api.MessageSubmit(ctx, SubmitMessage{
427 From: "mjl@mox.example",
428 To: []string{"mjl+to@mox.example"},
430 TextBody: fmt.Sprintf("%80s", "tést"),
433 // Send without special-use Sent mailbox.
434 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
435 api.MessageSubmit(ctx, SubmitMessage{
436 From: "mjl@mox.example",
437 To: []string{"mjl+to@mox.example"},
439 TextBody: fmt.Sprintf("%80s", "tést"),
442 // Message with From-address of another account.
443 tneedError(t, func() {
444 api.MessageSubmit(ctx, SubmitMessage{
445 From: "other@mox.example",
446 To: []string{"mjl+to@mox.example"},
451 // Message with unknown address.
452 tneedError(t, func() {
453 api.MessageSubmit(ctx, SubmitMessage{
454 From: "doesnotexist@mox.example",
455 To: []string{"mjl+to@mox.example"},
460 // Message without recipient.
461 tneedError(t, func() {
462 api.MessageSubmit(ctx, SubmitMessage{
463 From: "mjl@mox.example",
468 // Message from disabled domain.
469 tneedError(t, func() {
470 api.MessageSubmit(ctx, SubmitMessage{
471 From: "mjl@disabled.example",
472 To: []string{"mjl@mox.example"},
477 api.maxMessageSize = 1
478 tneedError(t, func() {
479 api.MessageSubmit(ctx, SubmitMessage{
480 From: "mjl@mox.example",
481 To: []string{"mjl+to@mox.example"},
482 Subject: "too large",
483 TextBody: "so many bytes",
486 api.maxMessageSize = 1024 * 1024
488 // Hit recipient limit.
489 tneedError(t, func() {
490 accConf, _ := acc.Conf()
491 for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
492 api.MessageSubmit(ctx, SubmitMessage{
493 From: fmt.Sprintf("user@mox%d.example", i),
499 // Hit message limit.
500 tneedError(t, func() {
501 accConf, _ := acc.Conf()
502 for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
503 api.MessageSubmit(ctx, SubmitMessage{
504 From: fmt.Sprintf("user@mox%d.example", i),
510 l, full := api.CompleteRecipient(ctx, "doesnotexist")
511 tcompare(t, len(l), 0)
512 tcompare(t, full, true)
513 l, full = api.CompleteRecipient(ctx, "cc2")
514 tcompare(t, l, []string{`"mjl, cc2" <mjl+cc2@mox.example>`, "mjl bcc2 <mjl+bcc2@mox.example>"})
515 tcompare(t, full, true)
518 resolver := dns.MockResolver{}
519 rs, err := recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
520 tcompare(t, err, nil)
521 tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
522 err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
523 tcheck(t, err, "insert recipient domain tls info")
524 rs, err = recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
525 tcompare(t, err, nil)
526 tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})
528 // Suggesting/adding/removing rulesets.
530 testSuggest := func(msgID int64, expListID string, expMsgFrom string) {
531 listID, msgFrom, isRemove, rcptTo, ruleset := api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
532 tcompare(t, listID, expListID)
533 tcompare(t, msgFrom, expMsgFrom)
534 tcompare(t, isRemove, false)
535 tcompare(t, rcptTo, "mox@other.example")
536 tcompare(t, ruleset == nil, false)
538 // Moving in opposite direction doesn't get a suggestion without the rule present.
539 _, _, _, _, rs0 := api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
540 tcompare(t, rs0 == nil, true)
542 api.RulesetAdd(ctx, rcptTo, *ruleset)
544 // Ruleset that exists won't get a suggestion again.
545 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
546 tcompare(t, ruleset == nil, true)
548 // Moving in oppositive direction, with rule present, gets the suggestion to remove.
549 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
550 tcompare(t, ruleset == nil, false)
552 api.RulesetRemove(ctx, rcptTo, *ruleset)
554 // If ListID/MsgFrom is marked as never, we won't get a suggestion.
555 api.RulesetMessageNever(ctx, rcptTo, expListID, expMsgFrom, false)
556 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
557 tcompare(t, ruleset == nil, true)
561 n, err = bstore.QueryDB[store.RulesetNoListID](ctx, acc.DB).Delete()
563 n, err = bstore.QueryDB[store.RulesetNoMsgFrom](ctx, acc.DB).Delete()
565 tcheck(t, err, "remove never-answer for listid/msgfrom")
567 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
568 tcompare(t, ruleset == nil, false)
570 // If Mailbox is marked as never, we won't get a suggestion.
571 api.RulesetMailboxNever(ctx, testbox1.ID, true)
572 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
573 tcompare(t, ruleset == nil, true)
575 n, err = bstore.QueryDB[store.RulesetNoMailbox](ctx, acc.DB).Delete()
576 tcheck(t, err, "remove never-answer for mailbox")
582 tdeliver(t, acc, inboxText)
583 testSuggest(inboxText.ID, "", "mjl@mox.example")
586 tdeliver(t, acc, inboxHTML)
587 testSuggest(inboxHTML.ID, "list.mox.example", "")