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/queue"
21 "github.com/mjl-/mox/store"
24func tneedErrorCode(t *testing.T, code string, fn func()) {
31 t.Fatalf("expected sherpa user error, saw success")
33 if err, ok := x.(*sherpa.Error); !ok {
35 t.Fatalf("expected sherpa error, saw %#v", x)
36 } else if err.Code != code {
38 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
45func tneedError(t *testing.T, fn func()) {
46 tneedErrorCode(t, "user:error", fn)
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) {
53 os.RemoveAll("../testdata/webmail/data")
55 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
56 mox.MustLoadConfig(true, false)
57 defer store.Switchboard()()
59 log := mlog.New("webmail", nil)
60 acc, err := store.OpenAccount(log, "mjl")
61 tcheck(t, err, "open account")
62 const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
63 const pw1 = "tést " // PRECIS normalized, with NFC.
64 err = acc.SetPassword(log, pw0)
65 tcheck(t, err, "set password")
68 pkglog.Check(err, "closing account")
71 var zerom store.Message
73 inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
74 inboxText = &testmsg{"Inbox", store.Flags{}, nil, msgText, zerom, 0}
75 inboxHTML = &testmsg{"Inbox", store.Flags{}, nil, msgHTML, zerom, 0}
76 inboxAlt = &testmsg{"Inbox", store.Flags{}, nil, msgAlt, zerom, 0}
77 inboxAltRel = &testmsg{"Inbox", store.Flags{}, nil, msgAltRel, zerom, 0}
78 inboxAttachments = &testmsg{"Inbox", store.Flags{}, nil, msgAttachments, zerom, 0}
79 testbox1Alt = &testmsg{"Testbox1", store.Flags{}, nil, msgAlt, zerom, 0}
80 rejectsMinimal = &testmsg{"Rejects", store.Flags{Junk: true}, nil, msgMinimal, zerom, 0}
82 var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal}
84 for _, tm := range testmsgs {
88 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
90 // Test login, and rate limiter.
91 loginReqInfo := requestInfo{"mjl@mox.example", "mjl", "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
92 loginctx := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo)
94 // Missing login token.
95 tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", pw0) })
97 // Login with loginToken.
98 loginCookie := &http.Cookie{Name: "webmaillogin"}
99 loginCookie.Value = api.LoginPrep(loginctx)
100 loginReqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
102 testLogin := func(username, password string, expErrCodes ...string) {
107 expErr := len(expErrCodes) > 0
108 if (x != nil) != expErr {
109 t.Fatalf("got %v, expected codes %v, for username %q, password %q", x, expErrCodes, username, password)
113 } else if err, ok := x.(*sherpa.Error); !ok {
114 t.Fatalf("got %#v, expected at most *sherpa.Error", x)
115 } else if !slices.Contains(expErrCodes, err.Code) {
116 t.Fatalf("got error code %q, expected %v", err.Code, expErrCodes)
120 api.Login(loginctx, loginCookie.Value, username, password)
122 testLogin("mjl@mox.example", pw0)
123 testLogin("mjl@mox.example", pw1)
124 testLogin("móx@mox.example", pw1) // NFC username
125 testLogin("mo\u0301x@mox.example", pw1) // NFD username
126 testLogin("mjl@mox.example", pw1+" ", "user:loginFailed")
127 testLogin("nouser@mox.example", pw0, "user:loginFailed")
128 testLogin("nouser@bad.example", pw0, "user:loginFailed")
129 for i := 3; i < 10; i++ {
130 testLogin("bad@bad.example", pw0, "user:loginFailed")
132 // Ensure rate limiter is triggered, also for slow tests.
133 for i := 0; i < 10; i++ {
134 testLogin("bad@bad.example", pw0, "user:loginFailed", "user:error")
136 testLogin("bad@bad.example", pw0, "user:error")
138 // Context with different IP, for clear rate limit history.
139 reqInfo := requestInfo{"mjl@mox.example", "mjl", "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
140 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
143 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
144 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
145 api.FlagsAdd(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
146 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`})
147 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`another`}) // No change.
148 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{}) // Nothing to do.
149 api.FlagsAdd(ctx, []int64{}, []string{}) // No messages, no flags.
150 api.FlagsAdd(ctx, []int64{}, []string{`custom`}) // No message, new flag.
151 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$junk`}) // Trigger retrain.
152 api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`$notjunk`}) // Trigger retrain.
153 api.FlagsAdd(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`}) // Trigger retrain, messages in different mailboxes.
154 api.FlagsAdd(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`, `newlabel`}) // Two mailboxes with counts and keywords changed.
155 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
156 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{``}) }) // Empty is invalid.
157 tneedError(t, func() { api.FlagsAdd(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) }) // Only predefined system flags.
159 // FlagsClear, inverse of FlagsAdd.
160 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\seen`, `customlabel`})
161 api.FlagsClear(ctx, []int64{inboxText.ID, inboxHTML.ID}, []string{`\seen`, `customlabel`})
162 api.FlagsClear(ctx, []int64{inboxText.ID, inboxText.ID}, []string{`\seen`, `customlabel`}) // Same message twice.
163 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
164 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`another`})
165 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{})
166 api.FlagsClear(ctx, []int64{}, []string{})
167 api.FlagsClear(ctx, []int64{}, []string{`custom`})
168 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$junk`})
169 api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`$notjunk`})
170 api.FlagsClear(ctx, []int64{inboxText.ID, testbox1Alt.ID}, []string{`$junk`, `$notjunk`})
171 api.FlagsClear(ctx, []int64{inboxHTML.ID, testbox1Alt.ID}, []string{`\Seen`}) // Two mailboxes with counts changed.
172 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{` bad syntax `}) })
173 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{``}) })
174 tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
176 // MailboxSetSpecialUse
177 var inbox, archive, sent, testbox1 store.Mailbox
178 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
179 get := func(k string, v any) store.Mailbox {
180 mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get()
181 tcheck(t, err, "get special-use mailbox")
185 sent = get("Sent", true)
186 archive = get("Archive", true)
190 inbox = get("Name", "Inbox")
191 testbox1 = get("Name", "Testbox1")
194 tcheck(t, err, "get mailboxes")
195 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: archive.ID, SpecialUse: store.SpecialUse{Draft: true}}) // Already set.
196 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true}}) // New draft mailbox.
197 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Sent: true}})
198 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Archive: true}})
199 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Trash: true}})
200 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Junk: true}})
201 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None
202 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{Draft: true, Sent: true, Archive: true, Trash: true, Junk: true}}) // All
203 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: testbox1.ID, SpecialUse: store.SpecialUse{}}) // None again.
204 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{Sent: true}}) // Sent, for sending mail later.
205 tneedError(t, func() { api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: 0}) })
208 api.MailboxRename(ctx, testbox1.ID, "Testbox2")
209 api.MailboxRename(ctx, testbox1.ID, "Test/A/B/Box1")
210 api.MailboxRename(ctx, testbox1.ID, "Test/A/Box1")
211 api.MailboxRename(ctx, testbox1.ID, "Testbox1")
212 tneedError(t, func() { api.MailboxRename(ctx, 0, "BadID") })
213 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Testbox1") }) // Already this name.
214 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Inbox") }) // Inbox not allowed.
215 tneedError(t, func() { api.MailboxRename(ctx, inbox.ID, "Binbox") }) // Inbox not allowed.
216 tneedError(t, func() { api.MailboxRename(ctx, testbox1.ID, "Archive") }) // Exists.
219 // todo: verify contents
220 api.ParsedMessage(ctx, inboxMinimal.ID)
221 api.ParsedMessage(ctx, inboxText.ID)
222 api.ParsedMessage(ctx, inboxHTML.ID)
223 api.ParsedMessage(ctx, inboxAlt.ID)
224 api.ParsedMessage(ctx, inboxAltRel.ID)
225 api.ParsedMessage(ctx, testbox1Alt.ID)
226 tneedError(t, func() { api.ParsedMessage(ctx, 0) })
227 tneedError(t, func() { api.ParsedMessage(ctx, testmsgs[len(testmsgs)-1].ID+1) })
230 api.MailboxDelete(ctx, testbox1.ID)
231 testa, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Test/A").Get()
232 tcheck(t, err, "get mailbox Test/A")
233 tneedError(t, func() { api.MailboxDelete(ctx, testa.ID) }) // Test/A/B still exists.
234 tneedError(t, func() { api.MailboxDelete(ctx, 0) }) // Bad ID.
235 tneedError(t, func() { api.MailboxDelete(ctx, testbox1.ID) }) // No longer exists.
236 tneedError(t, func() { api.MailboxDelete(ctx, inbox.ID) }) // Cannot remove inbox.
237 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
239 api.MailboxCreate(ctx, "Testbox1")
240 testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Testbox1").Get()
241 tcheck(t, err, "get testbox1")
242 tdeliver(t, acc, testbox1Alt)
245 api.MailboxEmpty(ctx, testbox1.ID)
246 tneedError(t, func() { api.ParsedMessage(ctx, testbox1Alt.ID) }) // Message was removed and no longer exists.
247 tneedError(t, func() { api.MailboxEmpty(ctx, 0) }) // Bad ID.
250 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, inbox.ID) }) // Message was removed (with MailboxEmpty above).
251 api.MessageMove(ctx, []int64{}, testbox1.ID) // No messages.
252 tdeliver(t, acc, testbox1Alt)
253 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID) }) // Already in destination mailbox.
254 tneedError(t, func() { api.MessageMove(ctx, []int64{}, 0) }) // Bad ID.
255 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}, testbox1.ID)
256 api.MessageMove(ctx, []int64{inboxMinimal.ID, inboxHTML.ID, testbox1Alt.ID}, inbox.ID) // From different mailboxes.
257 api.FlagsAdd(ctx, []int64{inboxMinimal.ID}, []string{`minimallabel`}) // For move.
258 api.MessageMove(ctx, []int64{inboxMinimal.ID}, testbox1.ID) // Move causes new label for destination mailbox.
259 api.MessageMove(ctx, []int64{rejectsMinimal.ID}, testbox1.ID) // Move causing readjustment of MailboxOrigID due to Rejects mailbox.
260 tneedError(t, func() { api.MessageMove(ctx, []int64{testbox1Alt.ID, inboxMinimal.ID}, testbox1.ID) }) // inboxMinimal already in destination.
262 api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID)
263 api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID)
266 api.MessageDelete(ctx, []int64{}) // No messages.
267 api.MessageDelete(ctx, []int64{inboxMinimal.ID, inboxHTML.ID}) // Same mailbox.
268 api.MessageDelete(ctx, []int64{inboxText.ID, testbox1Alt.ID, inboxAltRel.ID}) // Multiple mailboxes, multiple times.
269 tneedError(t, func() { api.MessageDelete(ctx, []int64{0}) }) // Bad ID.
270 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID + 999}) }) // Bad ID
271 tneedError(t, func() { api.MessageDelete(ctx, []int64{testbox1Alt.ID}) }) // Already removed.
272 tdeliver(t, acc, testbox1Alt)
273 tdeliver(t, acc, inboxAltRel)
276 queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
277 api.MessageSubmit(ctx, SubmitMessage{
278 From: "mjl@mox.example",
279 To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
280 Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
281 Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
282 Subject: "test email",
283 TextBody: "this is the content\n\ncheers,\nmox",
284 ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
285 UserAgent: "moxwebmail/dev",
287 // todo: check delivery of 6 messages to inbox, 1 to sent
289 // Reply with attachments.
290 api.MessageSubmit(ctx, SubmitMessage{
291 From: "mjl@mox.example",
292 To: []string{"mjl+to@mox.example"},
293 Subject: "Re: reply with attachments",
294 TextBody: "sending you these fake png files",
297 Filename: "test1.png",
298 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
301 Filename: "test1.png",
302 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
305 ResponseMessageID: testbox1Alt.ID,
307 // todo: check answered flag
309 // Forward with attachments.
310 api.MessageSubmit(ctx, SubmitMessage{
311 From: "mjl@mox.example",
312 To: []string{"mjl+to@mox.example"},
313 Subject: "Fwd: the original subject",
314 TextBody: "look what i got",
317 Filename: "test1.png",
318 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
321 ForwardAttachments: ForwardAttachments{
322 MessageID: inboxAltRel.ID,
323 Paths: [][]int{{1, 1}, {1, 1}},
326 ResponseMessageID: testbox1Alt.ID,
328 // todo: check forwarded flag, check it has the right attachments.
330 // Send from utf8 localpart.
331 api.MessageSubmit(ctx, SubmitMessage{
332 From: "møx@mox.example",
333 To: []string{"mjl+to@mox.example"},
337 // Send to utf8 localpart.
338 api.MessageSubmit(ctx, SubmitMessage{
339 From: "mjl@mox.example",
340 To: []string{"møx@mox.example"},
344 // Send to utf-8 text.
345 api.MessageSubmit(ctx, SubmitMessage{
346 From: "mjl@mox.example",
347 To: []string{"mjl+to@mox.example"},
349 TextBody: fmt.Sprintf("%80s", "tést"),
352 // Send without special-use Sent mailbox.
353 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
354 api.MessageSubmit(ctx, SubmitMessage{
355 From: "mjl@mox.example",
356 To: []string{"mjl+to@mox.example"},
358 TextBody: fmt.Sprintf("%80s", "tést"),
361 // Message with From-address of another account.
362 tneedError(t, func() {
363 api.MessageSubmit(ctx, SubmitMessage{
364 From: "other@mox.example",
365 To: []string{"mjl+to@mox.example"},
370 // Message with unknown address.
371 tneedError(t, func() {
372 api.MessageSubmit(ctx, SubmitMessage{
373 From: "doesnotexist@mox.example",
374 To: []string{"mjl+to@mox.example"},
379 // Message without recipient.
380 tneedError(t, func() {
381 api.MessageSubmit(ctx, SubmitMessage{
382 From: "mjl@mox.example",
387 api.maxMessageSize = 1
388 tneedError(t, func() {
389 api.MessageSubmit(ctx, SubmitMessage{
390 From: "mjl@mox.example",
391 To: []string{"mjl+to@mox.example"},
392 Subject: "too large",
393 TextBody: "so many bytes",
396 api.maxMessageSize = 1024 * 1024
398 // Hit recipient limit.
399 tneedError(t, func() {
400 accConf, _ := acc.Conf()
401 for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
402 api.MessageSubmit(ctx, SubmitMessage{
403 From: fmt.Sprintf("user@mox%d.example", i),
409 // Hit message limit.
410 tneedError(t, func() {
411 accConf, _ := acc.Conf()
412 for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
413 api.MessageSubmit(ctx, SubmitMessage{
414 From: fmt.Sprintf("user@mox%d.example", i),
420 l, full := api.CompleteRecipient(ctx, "doesnotexist")
421 tcompare(t, len(l), 0)
422 tcompare(t, full, true)
423 l, full = api.CompleteRecipient(ctx, "cc2")
424 tcompare(t, l, []string{"mjl cc2 <mjl+cc2@mox.example>"})
425 tcompare(t, full, true)
428 resolver := dns.MockResolver{}
429 rs, err := recipientSecurity(ctx, resolver, "mjl@a.mox.example")
430 tcompare(t, err, nil)
431 tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
432 err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
433 tcheck(t, err, "insert recipient domain tls info")
434 rs, err = recipientSecurity(ctx, resolver, "mjl@a.mox.example")
435 tcompare(t, err, nil)
436 tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})