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.MessageItem(ctx, inboxMinimal.ID)
255 api.MessageItem(ctx, inboxHTML.ID)
256 api.MessageItem(ctx, inboxAlt.ID)
257 api.MessageItem(ctx, inboxAltRel.ID)
258 api.MessageItem(ctx, testbox1Alt.ID)
259 tneedError(t, func() { api.MessageItem(ctx, 0) })
260 tneedError(t, func() { api.MessageItem(ctx, testmsgs[len(testmsgs)-1].ID+1) })
263 // todo: verify contents
264 api.ParsedMessage(ctx, inboxMinimal.ID)
265 api.ParsedMessage(ctx, inboxHTML.ID)
266 api.ParsedMessage(ctx, inboxAlt.ID)
267 api.ParsedMessage(ctx, inboxAltRel.ID)
268 api.ParsedMessage(ctx, testbox1Alt.ID)
269 tneedError(t, func() { api.ParsedMessage(ctx, 0) })
270 tneedError(t, func() { api.ParsedMessage(ctx, testmsgs[len(testmsgs)-1].ID+1) })
271 pm := api.ParsedMessage(ctx, inboxText.ID)
272 tcompare(t, pm.ViewMode, store.ModeText)
274 api.FromAddressSettingsSave(ctx, store.FromAddressSettings{FromAddress: "mjl@mox.example", ViewMode: store.ModeHTMLExt})
275 pm = api.ParsedMessage(ctx, inboxText.ID)
276 tcompare(t, pm.ViewMode, store.ModeHTMLExt)
279 api.MailboxDelete(ctx, testbox1.ID)
280 testa, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Test/A").Get()
281 tcheck(t, err, "get mailbox Test/A")
282 tneedError(t, func() { api.MailboxDelete(ctx, testa.ID) }) // Test/A/B still exists.
283 tneedError(t, func() { api.MailboxDelete(ctx, 0) }) // Bad ID.
284 tneedError(t, func() { api.MailboxDelete(ctx, testbox1.ID) }) // No longer exists.
285 tneedError(t, func() { api.MailboxDelete(ctx, inbox.ID) }) // Cannot remove inbox.
286 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
288 api.MailboxCreate(ctx, "Testbox1")
289 testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Expunged", false).FilterEqual("Name", "Testbox1").Get()
290 tcheck(t, err, "get testbox1")
291 tdeliver(t, acc, testbox1Alt)
294 api.MailboxEmpty(ctx, testbox1.ID)
295 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
296 tneedError(t, func() { api.MailboxEmpty(ctx, 0) }) // Bad ID.
299 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, inbox.ID, false) }) // Message was removed (with MailboxEmpty above).
300 api.MessageMove(ctx, []int64{}, testbox1.ID, false) // No messages.
301 tdeliver(t, acc, testbox1Alt)
302 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID, false) }) // Already in destination mailbox.
303 tneedError(t, func() { api.MessageMove(ctx, []int64{}, 0, false) }) // Bad ID.
304 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}, testbox1.ID, false)
305 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID, testbox1Alt.ID}, inbox.ID, true) // From different mailboxes.
306 api.FlagsAdd(ctx, []int64{inboxMinimal.ID}, []string{`minimallabel`}) // For move.
307 api.MessageMove(ctx, []int64{inboxMinimal.ID}, testbox1.ID, false) // Move causes new label for destination mailbox.
308 api.MessageMove(ctx, []int64{rejectsMinimal.ID}, testbox1.ID, false) // Move causing readjustment of MailboxOrigID due to Rejects mailbox.
309 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID, inboxMinimal.ID}, testbox1.ID, false) }) // inboxMinimal already in destination.
311 api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID, false)
312 api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID, false)
315 api.MessageDelete(ctx, []int64{}) // No messages.
316 api.MessageDelete(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}) // Same mailbox.
317 api.MessageDelete(ctx, []int64{inboxText.ID, testbox1Alt.ID, inboxAltRel.ID}) // Multiple mailboxes, multiple times.
318 tneedError(t, func() { api.MessageDelete(ctx, []int64{0}) }) // Bad ID.
319 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID + 999}) }) // Bad ID
320 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID}) }) // Already removed.
321 tdeliver(t, acc, testbox1Alt)
322 tdeliver(t, acc, inboxAltRel)
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>",
335 draftID = api.MessageCompose(ctx, ComposeMessage{
336 From: "mjl@mox.example",
337 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
338 Cc: []string{"mjl+cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
339 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
340 Subject: "test email",
341 TextBody: "this is the content\n\ncheers,\nmox",
342 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
343 DraftMessageID: draftID,
346 // MessageFindMessageID
347 msgID := api.MessageFindMessageID(ctx, "<absent@localhost>")
348 tcompare(t, msgID, int64(0))
351 queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
353 tcheck(t, err, "queue init")
354 defer queue.Shutdown()
355 api.MessageSubmit(ctx, SubmitMessage{
356 From: "mjl@mox.example",
357 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
358 Cc: []string{"mjl+cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
359 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
360 Subject: "test email",
361 TextBody: "this is the content\n\ncheers,\nmox",
362 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
363 UserAgent: "moxwebmail/dev",
364 DraftMessageID: draftID,
366 // todo: check delivery of 6 messages to inbox, 1 to sent
368 api.MessageSubmit(ctx, SubmitMessage{
369 From: "mjl-altcatchall@mox.example",
370 To: []string{"mjl-to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
371 Cc: []string{"mjl-cc@mox.example", `"mjl, cc2" <mjl+cc2@mox.example>`},
372 Bcc: []string{"mjl-bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
373 Subject: "test email",
374 TextBody: "this is the content\n\ncheers,\nmox",
375 ReplyTo: "mjl replyto <mjl-replyto@mox.example>",
376 UserAgent: "moxwebmail/dev",
378 // todo: check delivery of 6 messages to inbox, 1 to sent
380 // Reply with attachments.
381 api.MessageSubmit(ctx, SubmitMessage{
382 From: "mjl@mox.example",
383 To: []string{"mjl+to@mox.example"},
384 Subject: "Re: reply with attachments",
385 TextBody: "sending you these fake png files",
388 Filename: "test1.png",
389 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
392 Filename: "test1.png",
393 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
396 ResponseMessageID: testbox1Alt.ID,
398 // todo: check answered flag
400 // Forward with attachments.
401 api.MessageSubmit(ctx, SubmitMessage{
402 From: "mjl@mox.example",
403 To: []string{"mjl+to@mox.example"},
404 Subject: "Fwd: the original subject",
405 TextBody: "look what i got",
408 Filename: "test1.png",
409 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
412 ForwardAttachments: ForwardAttachments{
413 MessageID: inboxAltRel.ID,
414 Paths: [][]int{{1, 1}, {1, 1}},
417 ResponseMessageID: testbox1Alt.ID,
419 // todo: check forwarded flag, check it has the right attachments.
421 // Send from utf8 localpart.
422 api.MessageSubmit(ctx, SubmitMessage{
423 From: "møx@mox.example",
424 To: []string{"mjl+to@mox.example"},
428 // Send to utf8 localpart.
429 api.MessageSubmit(ctx, SubmitMessage{
430 From: "mjl@mox.example",
431 To: []string{"møx@mox.example"},
435 // Send to utf-8 text.
436 api.MessageSubmit(ctx, SubmitMessage{
437 From: "mjl@mox.example",
438 To: []string{"mjl+to@mox.example"},
440 TextBody: fmt.Sprintf("%80s", "tést"),
443 // Send without special-use Sent mailbox.
444 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
445 api.MessageSubmit(ctx, SubmitMessage{
446 From: "mjl@mox.example",
447 To: []string{"mjl+to@mox.example"},
449 TextBody: fmt.Sprintf("%80s", "tést"),
452 // Message with From-address of another account.
453 tneedError(t, func() {
454 api.MessageSubmit(ctx, SubmitMessage{
455 From: "other@mox.example",
456 To: []string{"mjl+to@mox.example"},
461 // Message with unknown address.
462 tneedError(t, func() {
463 api.MessageSubmit(ctx, SubmitMessage{
464 From: "doesnotexist@mox.example",
465 To: []string{"mjl+to@mox.example"},
470 // Message without recipient.
471 tneedError(t, func() {
472 api.MessageSubmit(ctx, SubmitMessage{
473 From: "mjl@mox.example",
478 // Message from disabled domain.
479 tneedError(t, func() {
480 api.MessageSubmit(ctx, SubmitMessage{
481 From: "mjl@disabled.example",
482 To: []string{"mjl@mox.example"},
487 api.maxMessageSize = 1
488 tneedError(t, func() {
489 api.MessageSubmit(ctx, SubmitMessage{
490 From: "mjl@mox.example",
491 To: []string{"mjl+to@mox.example"},
492 Subject: "too large",
493 TextBody: "so many bytes",
496 api.maxMessageSize = 1024 * 1024
498 // Hit recipient limit.
499 tneedError(t, func() {
500 accConf, _ := acc.Conf()
501 for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
502 api.MessageSubmit(ctx, SubmitMessage{
503 From: fmt.Sprintf("user@mox%d.example", i),
509 // Hit message limit.
510 tneedError(t, func() {
511 accConf, _ := acc.Conf()
512 for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
513 api.MessageSubmit(ctx, SubmitMessage{
514 From: fmt.Sprintf("user@mox%d.example", i),
520 l, full := api.CompleteRecipient(ctx, "doesnotexist")
521 tcompare(t, len(l), 0)
522 tcompare(t, full, true)
523 l, full = api.CompleteRecipient(ctx, "cc2")
524 tcompare(t, l, []string{`"mjl, cc2" <mjl+cc2@mox.example>`, "mjl bcc2 <mjl+bcc2@mox.example>"})
525 tcompare(t, full, true)
528 resolver := dns.MockResolver{}
529 rs, err := recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
530 tcompare(t, err, nil)
531 tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
532 err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
533 tcheck(t, err, "insert recipient domain tls info")
534 rs, err = recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
535 tcompare(t, err, nil)
536 tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})
538 // Suggesting/adding/removing rulesets.
540 testSuggest := func(msgID int64, expListID string, expMsgFrom string) {
541 listID, msgFrom, isRemove, rcptTo, ruleset := api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
542 tcompare(t, listID, expListID)
543 tcompare(t, msgFrom, expMsgFrom)
544 tcompare(t, isRemove, false)
545 tcompare(t, rcptTo, "mox@other.example")
546 tcompare(t, ruleset == nil, false)
548 // Moving in opposite direction doesn't get a suggestion without the rule present.
549 _, _, _, _, rs0 := api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
550 tcompare(t, rs0 == nil, true)
552 api.RulesetAdd(ctx, rcptTo, *ruleset)
554 // Ruleset that exists won't get a suggestion again.
555 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
556 tcompare(t, ruleset == nil, true)
558 // Moving in oppositive direction, with rule present, gets the suggestion to remove.
559 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
560 tcompare(t, ruleset == nil, false)
562 api.RulesetRemove(ctx, rcptTo, *ruleset)
564 // If ListID/MsgFrom is marked as never, we won't get a suggestion.
565 api.RulesetMessageNever(ctx, rcptTo, expListID, expMsgFrom, false)
566 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
567 tcompare(t, ruleset == nil, true)
571 n, err = bstore.QueryDB[store.RulesetNoListID](ctx, acc.DB).Delete()
573 n, err = bstore.QueryDB[store.RulesetNoMsgFrom](ctx, acc.DB).Delete()
575 tcheck(t, err, "remove never-answer for listid/msgfrom")
577 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
578 tcompare(t, ruleset == nil, false)
580 // If Mailbox is marked as never, we won't get a suggestion.
581 api.RulesetMailboxNever(ctx, testbox1.ID, true)
582 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
583 tcompare(t, ruleset == nil, true)
585 n, err = bstore.QueryDB[store.RulesetNoMailbox](ctx, acc.DB).Delete()
586 tcheck(t, err, "remove never-answer for mailbox")
592 tdeliver(t, acc, inboxText)
593 testSuggest(inboxText.ID, "", "mjl@mox.example")
596 tdeliver(t, acc, inboxHTML)
597 testSuggest(inboxHTML.ID, "list.mox.example", "")