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 err := store.Init(ctxbg)
61 tcheck(t, err, "store init")
62 defer func() {
63 err := store.Close()
64 tcheck(t, err, "store close")
65 }()
66
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")
76 defer func() {
77 err := mtastsdb.Close()
78 tcheck(t, err, "mtastsdb close")
79 err = acc.Close()
80 pkglog.Check(err, "closing account")
81 acc.CheckClosed()
82 }()
83
84 var zerom store.Message
85 var (
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}
94 )
95 var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal}
96
97 for _, tm := range testmsgs {
98 tdeliver(t, acc, tm)
99 }
100
101 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
102
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)
106
107 // Missing login token.
108 tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", pw0) })
109
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()}}
114
115 testLogin := func(username, password string, expErrCodes ...string) {
116 t.Helper()
117
118 defer func() {
119 x := recover()
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))
123 }
124 if x == nil {
125 return
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)
130 }
131 }()
132
133 api.Login(loginctx, loginCookie.Value, username, password)
134 }
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")
144 }
145 // Ensure rate limiter is triggered, also for slow tests.
146 for i := 0; i < 10; i++ {
147 testLogin("bad@bad.example", pw0, "user:loginFailed", "user:error")
148 }
149 testLogin("bad@bad.example", pw0, "user:error")
150
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")
155 acc2.Close()
156 tcheck(t, err, "close account")
157
158 mox.LimitersInit()
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") })
166
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)
170
171 // FlagsAdd
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.
187
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`}) })
204
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(k, v).Get()
210 tcheck(t, err, "get special-use mailbox")
211 return mb
212 }
213 drafts = get("Draft", true)
214 sent = get("Sent", true)
215 archive = get("Archive", true)
216 get("Trash", true)
217 get("Junk", true)
218
219 inbox = get("Name", "Inbox")
220 testbox1 = get("Name", "Testbox1")
221 return nil
222 })
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}) })
235
236 // MailboxesMarkRead
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.
240
241 // MailboxRename
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.
251
252 // ParsedMessage
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)
263
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)
267
268 // MailboxDelete
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.
277
278 api.MailboxCreate(ctx, "Testbox1")
279 testbox1, err = bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Name", "Testbox1").Get()
280 tcheck(t, err, "get testbox1")
281 tdeliver(t, acc, testbox1Alt)
282
283 // MailboxEmpty
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.
287
288 // MessageMove
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.
300 // Restore.
301 api.MessageMove(ctx, []int64{inboxMinimal.ID}, inbox.ID)
302 api.MessageMove(ctx, []int64{testbox1Alt.ID}, testbox1.ID)
303
304 // MessageDelete
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)
313
314 // MessageCompose
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>",
323 }, drafts.ID)
324 // Replace draft.
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,
334 }, drafts.ID)
335
336 // MessageFindMessageID
337 msgID := api.MessageFindMessageID(ctx, "<absent@localhost>")
338 tcompare(t, msgID, int64(0))
339
340 // MessageSubmit
341 queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
342 err = queue.Init()
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,
355 })
356 // todo: check delivery of 6 messages to inbox, 1 to sent
357
358 // Reply with attachments.
359 api.MessageSubmit(ctx, SubmitMessage{
360 From: "mjl@mox.example",
361 To: []string{"mjl+to@mox.example"},
362 Subject: "Re: reply with attachments",
363 TextBody: "sending you these fake png files",
364 Attachments: []File{
365 {
366 Filename: "test1.png",
367 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
368 },
369 {
370 Filename: "test1.png",
371 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
372 },
373 },
374 ResponseMessageID: testbox1Alt.ID,
375 })
376 // todo: check answered flag
377
378 // Forward with attachments.
379 api.MessageSubmit(ctx, SubmitMessage{
380 From: "mjl@mox.example",
381 To: []string{"mjl+to@mox.example"},
382 Subject: "Fwd: the original subject",
383 TextBody: "look what i got",
384 Attachments: []File{
385 {
386 Filename: "test1.png",
387 DataURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
388 },
389 },
390 ForwardAttachments: ForwardAttachments{
391 MessageID: inboxAltRel.ID,
392 Paths: [][]int{{1, 1}, {1, 1}},
393 },
394 IsForward: true,
395 ResponseMessageID: testbox1Alt.ID,
396 })
397 // todo: check forwarded flag, check it has the right attachments.
398
399 // Send from utf8 localpart.
400 api.MessageSubmit(ctx, SubmitMessage{
401 From: "møx@mox.example",
402 To: []string{"mjl+to@mox.example"},
403 TextBody: "test",
404 })
405
406 // Send to utf8 localpart.
407 api.MessageSubmit(ctx, SubmitMessage{
408 From: "mjl@mox.example",
409 To: []string{"møx@mox.example"},
410 TextBody: "test",
411 })
412
413 // Send to utf-8 text.
414 api.MessageSubmit(ctx, SubmitMessage{
415 From: "mjl@mox.example",
416 To: []string{"mjl+to@mox.example"},
417 Subject: "hi ☺",
418 TextBody: fmt.Sprintf("%80s", "tést"),
419 })
420
421 // Send without special-use Sent mailbox.
422 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: sent.ID, SpecialUse: store.SpecialUse{}})
423 api.MessageSubmit(ctx, SubmitMessage{
424 From: "mjl@mox.example",
425 To: []string{"mjl+to@mox.example"},
426 Subject: "hi ☺",
427 TextBody: fmt.Sprintf("%80s", "tést"),
428 })
429
430 // Message with From-address of another account.
431 tneedError(t, func() {
432 api.MessageSubmit(ctx, SubmitMessage{
433 From: "other@mox.example",
434 To: []string{"mjl+to@mox.example"},
435 TextBody: "test",
436 })
437 })
438
439 // Message with unknown address.
440 tneedError(t, func() {
441 api.MessageSubmit(ctx, SubmitMessage{
442 From: "doesnotexist@mox.example",
443 To: []string{"mjl+to@mox.example"},
444 TextBody: "test",
445 })
446 })
447
448 // Message without recipient.
449 tneedError(t, func() {
450 api.MessageSubmit(ctx, SubmitMessage{
451 From: "mjl@mox.example",
452 TextBody: "test",
453 })
454 })
455
456 // Message from disabled domain.
457 tneedError(t, func() {
458 api.MessageSubmit(ctx, SubmitMessage{
459 From: "mjl@disabled.example",
460 To: []string{"mjl@mox.example"},
461 TextBody: "test",
462 })
463 })
464
465 api.maxMessageSize = 1
466 tneedError(t, func() {
467 api.MessageSubmit(ctx, SubmitMessage{
468 From: "mjl@mox.example",
469 To: []string{"mjl+to@mox.example"},
470 Subject: "too large",
471 TextBody: "so many bytes",
472 })
473 })
474 api.maxMessageSize = 1024 * 1024
475
476 // Hit recipient limit.
477 tneedError(t, func() {
478 accConf, _ := acc.Conf()
479 for i := 0; i <= accConf.MaxFirstTimeRecipientsPerDay; i++ {
480 api.MessageSubmit(ctx, SubmitMessage{
481 From: fmt.Sprintf("user@mox%d.example", i),
482 TextBody: "test",
483 })
484 }
485 })
486
487 // Hit message limit.
488 tneedError(t, func() {
489 accConf, _ := acc.Conf()
490 for i := 0; i <= accConf.MaxOutgoingMessagesPerDay; i++ {
491 api.MessageSubmit(ctx, SubmitMessage{
492 From: fmt.Sprintf("user@mox%d.example", i),
493 TextBody: "test",
494 })
495 }
496 })
497
498 l, full := api.CompleteRecipient(ctx, "doesnotexist")
499 tcompare(t, len(l), 0)
500 tcompare(t, full, true)
501 l, full = api.CompleteRecipient(ctx, "cc2")
502 tcompare(t, l, []string{"mjl cc2 <mjl+cc2@mox.example>", "mjl bcc2 <mjl+bcc2@mox.example>"})
503 tcompare(t, full, true)
504
505 // RecipientSecurity
506 resolver := dns.MockResolver{}
507 rs, err := recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
508 tcompare(t, err, nil)
509 tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
510 err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
511 tcheck(t, err, "insert recipient domain tls info")
512 rs, err = recipientSecurity(ctx, log, resolver, "mjl@a.mox.example")
513 tcompare(t, err, nil)
514 tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})
515
516 // Suggesting/adding/removing rulesets.
517
518 testSuggest := func(msgID int64, expListID string, expMsgFrom string) {
519 listID, msgFrom, isRemove, rcptTo, ruleset := api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
520 tcompare(t, listID, expListID)
521 tcompare(t, msgFrom, expMsgFrom)
522 tcompare(t, isRemove, false)
523 tcompare(t, rcptTo, "mox@other.example")
524 tcompare(t, ruleset == nil, false)
525
526 // Moving in opposite direction doesn't get a suggestion without the rule present.
527 _, _, _, _, rs0 := api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
528 tcompare(t, rs0 == nil, true)
529
530 api.RulesetAdd(ctx, rcptTo, *ruleset)
531
532 // Ruleset that exists won't get a suggestion again.
533 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
534 tcompare(t, ruleset == nil, true)
535
536 // Moving in oppositive direction, with rule present, gets the suggestion to remove.
537 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, testbox1.ID, inbox.ID)
538 tcompare(t, ruleset == nil, false)
539
540 api.RulesetRemove(ctx, rcptTo, *ruleset)
541
542 // If ListID/MsgFrom is marked as never, we won't get a suggestion.
543 api.RulesetMessageNever(ctx, rcptTo, expListID, expMsgFrom, false)
544 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
545 tcompare(t, ruleset == nil, true)
546
547 var n int
548 if expListID != "" {
549 n, err = bstore.QueryDB[store.RulesetNoListID](ctx, acc.DB).Delete()
550 } else {
551 n, err = bstore.QueryDB[store.RulesetNoMsgFrom](ctx, acc.DB).Delete()
552 }
553 tcheck(t, err, "remove never-answer for listid/msgfrom")
554 tcompare(t, n, 1)
555 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
556 tcompare(t, ruleset == nil, false)
557
558 // If Mailbox is marked as never, we won't get a suggestion.
559 api.RulesetMailboxNever(ctx, testbox1.ID, true)
560 _, _, _, _, ruleset = api.RulesetSuggestMove(ctx, msgID, inbox.ID, testbox1.ID)
561 tcompare(t, ruleset == nil, true)
562
563 n, err = bstore.QueryDB[store.RulesetNoMailbox](ctx, acc.DB).Delete()
564 tcheck(t, err, "remove never-answer for mailbox")
565 tcompare(t, n, 1)
566
567 }
568
569 // For MsgFrom.
570 tdeliver(t, acc, inboxText)
571 testSuggest(inboxText.ID, "", "mjl@mox.example")
572
573 // For List-Id.
574 tdeliver(t, acc, inboxHTML)
575 testSuggest(inboxHTML.ID, "list.mox.example", "")
576}
577