22 "github.com/mjl-/mox/message"
23 "github.com/mjl-/mox/mlog"
24 "github.com/mjl-/mox/mox-"
25 "github.com/mjl-/mox/queue"
26 "github.com/mjl-/mox/store"
27 "github.com/mjl-/mox/webapi"
28 "github.com/mjl-/mox/webhook"
31var ctxbg = context.Background()
33func tcheckf(t *testing.T, err error, format string, args ...any) {
36 t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
40func tcompare(t *testing.T, got, expect any) {
42 if !reflect.DeepEqual(got, expect) {
43 t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
47func terrcode(t *testing.T, err error, code string) {
50 t.Fatalf("no error, expected error with code %q", code)
52 if xerr, ok := err.(webapi.Error); !ok {
53 t.Fatalf("got %v, expected webapi error with code %q", err, code)
54 } else if xerr.Code != code {
55 t.Fatalf("got error code %q, expected %q", xerr.Code, code)
59func TestServer(t *testing.T) {
61 os.RemoveAll("../testdata/webapisrv/data")
63 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webapisrv/mox.conf")
64 mox.MustLoadConfig(true, false)
65 defer store.Switchboard()()
67 tcheckf(t, err, "queue init")
68 defer queue.Shutdown()
70 log := mlog.New("webapisrv", nil)
71 acc, err := store.OpenAccount(log, "mjl")
72 tcheckf(t, err, "open account")
73 const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
74 const pw1 = "tést " // PRECIS normalized, with NFC.
75 err = acc.SetPassword(log, pw0)
76 tcheckf(t, err, "set password")
79 log.Check(err, "closing account")
83 s := NewServer(100*1024, "/webapi/", false).(server)
84 hs := httptest.NewServer(s)
87 // server expects the mount path to be stripped already.
88 client := webapi.Client{BaseURL: hs.URL + "/v0/", Username: "mjl@mox.example", Password: pw0}
90 testHTTPHdrsBody := func(s server, method, path string, headers map[string]string, body string, expCode int, expTooMany bool, expCT, expErrCode string) {
93 r := httptest.NewRequest(method, path, strings.NewReader(body))
94 for k, v := range headers {
97 w := httptest.NewRecorder()
100 if res.StatusCode != http.StatusTooManyRequests || !expTooMany {
101 tcompare(t, res.StatusCode, expCode)
104 tcompare(t, res.Header.Get("Content-Type"), expCT)
106 if expErrCode != "" {
107 dec := json.NewDecoder(res.Body)
108 dec.DisallowUnknownFields()
109 var apierr webapi.Error
110 err := dec.Decode(&apierr)
111 tcheckf(t, err, "decoding json error")
112 tcompare(t, apierr.Code, expErrCode)
115 testHTTP := func(method, path string, expCode int, expCT string) {
117 testHTTPHdrsBody(s, method, path, nil, "", expCode, false, expCT, "")
120 testHTTP("GET", "/", http.StatusSeeOther, "")
121 testHTTP("POST", "/", http.StatusMethodNotAllowed, "")
122 testHTTP("GET", "/v0/", http.StatusOK, "text/html; charset=utf-8")
123 testHTTP("GET", "/other/", http.StatusNotFound, "")
124 testHTTP("GET", "/v0/Send", http.StatusOK, "text/html; charset=utf-8")
125 testHTTP("GET", "/v0/MessageRawGet", http.StatusOK, "text/html; charset=utf-8")
126 testHTTP("GET", "/v0/Bogus", http.StatusNotFound, "")
127 testHTTP("PUT", "/v0/Send", http.StatusMethodNotAllowed, "")
128 testHTTP("POST", "/v0/Send", http.StatusUnauthorized, "")
130 for i := 0; i < 11; i++ {
131 // Missing auth doesn't trigger auth rate limiter.
132 testHTTP("POST", "/v0/Send", http.StatusUnauthorized, "")
134 for i := 0; i < 21; i++ {
136 expCode := http.StatusUnauthorized
139 expCode = http.StatusTooManyRequests
141 testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:badpassword"))}, "", expCode, tooMany, "", "")
145 // Request with missing X-Forwarded-For.
146 sfwd := NewServer(100*1024, "/webapi/", true).(server)
147 testHTTPHdrsBody(sfwd, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:badpassword"))}, "", http.StatusInternalServerError, false, "", "")
149 // Body must be form, not JSON.
150 authz := "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:"+pw1))
151 testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Content-Type": "application/json", "Authorization": authz}, "{}", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
152 testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Content-Type": "multipart/form-data", "Authorization": authz}, "not formdata", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
153 formAuth := map[string]string{
154 "Content-Type": "application/x-www-form-urlencoded",
155 "Authorization": authz,
157 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "not encoded\n\n", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
158 // Missing "request".
159 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
160 // "request" must be JSON.
161 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "request=notjson", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
162 // "request" must be JSON object.
163 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "request=[]", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
165 // Send message. Look for the message in the queue.
168 sendReq := webapi.SendRequest{
169 Message: webapi.Message{
170 From: []webapi.NameAddress{{Name: "møx", Address: "mjl@mox.example"}},
171 To: []webapi.NameAddress{{Name: "móx", Address: "mjl+to@mox.example"}, {Address: "mjl+to2@mox.example"}},
172 CC: []webapi.NameAddress{{Name: "möx", Address: "mjl+cc@mox.example"}},
173 BCC: []webapi.NameAddress{{Name: "møx", Address: "mjl+bcc@mox.example"}},
174 ReplyTo: []webapi.NameAddress{{Name: "reply1", Address: "mox+reply1@mox.example"}, {Name: "reply2", Address: "mox+reply2@mox.example"}},
175 MessageID: "<random@localhost>",
176 References: []string{"<messageid0@localhost>", "<messageid1@localhost>"},
178 Subject: "¡hello world!",
180 HTML: `<html><img src="cid:x" /></html>`, // Newline will be added.
182 Extra: map[string]string{"a": "123"},
183 Headers: [][2]string{{"x-custom", "header"}},
184 AlternativeFiles: []webapi.File{
187 ContentType: "text/calendar",
188 Data: base64.StdEncoding.EncodeToString([]byte("ics data...")),
191 InlineFiles: []webapi.File{
194 ContentType: "image/png",
196 Data: base64.StdEncoding.EncodeToString([]byte("png data")),
199 AttachedFiles: []webapi.File{
201 Data: base64.StdEncoding.EncodeToString([]byte("%PDF-")), // Should be detected as PDF.
208 sendResp, err := client.Send(ctxbg, sendReq)
209 tcheckf(t, err, "send message")
210 tcompare(t, sendResp.MessageID, sendReq.Message.MessageID)
211 tcompare(t, len(sendResp.Submissions), 2+1+1) // 2 to, 1 cc, 1 bcc
212 subs := sendResp.Submissions
213 tcompare(t, subs[0].Address, "mjl+to@mox.example")
214 tcompare(t, subs[1].Address, "mjl+to2@mox.example")
215 tcompare(t, subs[2].Address, "mjl+cc@mox.example")
216 tcompare(t, subs[3].Address, "mjl+bcc@mox.example")
217 tcompare(t, subs[3].QueueMsgID, subs[0].QueueMsgID+3)
218 tcompare(t, subs[0].FromID, "")
219 // todo: look in queue for parameters. parse the message.
221 // Send a custom multipart/form-data POST, with different request parameters, and
223 var sb strings.Builder
224 mp := multipart.NewWriter(&sb)
225 fdSendReq := webapi.SendRequest{
226 Message: webapi.Message{
227 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
228 // Let server assign date, message-id.
232 // Don't let server add its own user-agent.
233 Headers: [][2]string{{"User-Agent", "test"}},
235 sendReqBuf, err := json.Marshal(fdSendReq)
236 tcheckf(t, err, "send request")
237 mp.WriteField("request", string(sendReqBuf))
239 // One alternative file.
240 pw, err := mp.CreateFormFile("alternativefile", "test.ics")
241 tcheckf(t, err, "create alternative ics file")
242 _, err = fmt.Fprint(pw, "ICS...")
243 tcheckf(t, err, "write ics")
246 pw, err = mp.CreateFormFile("inlinefile", "test.pdf")
247 tcheckf(t, err, "create inline pdf file")
248 _, err = fmt.Fprint(pw, "%PDF-")
249 tcheckf(t, err, "write pdf")
250 pw, err = mp.CreateFormFile("inlinefile", "test.pdf")
251 tcheckf(t, err, "create second inline pdf file")
252 _, err = fmt.Fprint(pw, "%PDF-")
253 tcheckf(t, err, "write second pdf")
256 fh := textproto.MIMEHeader{}
257 fh.Set("Content-Disposition", `form-data; name="attachedfile"; filename="test.pdf"`)
258 fh.Set("Content-ID", "<testpdf>")
259 pw, err = mp.CreatePart(fh)
260 tcheckf(t, err, "create attached pdf file")
261 _, err = fmt.Fprint(pw, "%PDF-")
262 tcheckf(t, err, "write attached pdf")
263 fdct := mp.FormDataContentType()
265 tcheckf(t, err, "close multipart")
267 // Perform custom POST.
268 req, err := http.NewRequest("POST", hs.URL+"/v0/Send", strings.NewReader(sb.String()))
269 tcheckf(t, err, "new request")
270 req.Header.Set("Content-Type", fdct)
271 // Use a unique MAIL FROM id when delivering.
272 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("mjl+fromid@mox.example:"+pw1)))
273 resp, err := http.DefaultClient.Do(req)
274 tcheckf(t, err, "request multipart/form-data")
275 tcompare(t, resp.StatusCode, http.StatusOK)
276 var sendRes webapi.SendResult
277 err = json.NewDecoder(resp.Body).Decode(&sendRes)
278 tcheckf(t, err, "parse send response")
279 tcompare(t, sendRes.MessageID != "", true)
280 tcompare(t, len(sendRes.Submissions), 1)
281 tcompare(t, sendRes.Submissions[0].FromID != "", true)
283 // Trigger various error conditions.
284 _, err = client.Send(ctxbg, webapi.SendRequest{
285 Message: webapi.Message{
286 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
290 terrcode(t, err, "missingBody")
292 _, err = client.Send(ctxbg, webapi.SendRequest{
293 Message: webapi.Message{
294 From: []webapi.NameAddress{{Address: "other@mox.example"}},
295 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
300 terrcode(t, err, "badFrom")
302 _, err = client.Send(ctxbg, webapi.SendRequest{
303 Message: webapi.Message{
304 From: []webapi.NameAddress{{Address: "mox@mox.example"}, {Address: "mox@mox.example"}},
305 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
310 terrcode(t, err, "multipleFrom")
312 _, err = client.Send(ctxbg, webapi.SendRequest{Message: webapi.Message{Subject: "test", Text: "hi"}})
313 terrcode(t, err, "noRecipients")
315 _, err = client.Send(ctxbg, webapi.SendRequest{
316 Message: webapi.Message{
317 MessageID: "missingltgt@localhost",
318 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
323 terrcode(t, err, "malformedMessageID")
325 _, err = client.Send(ctxbg, webapi.SendRequest{
326 Message: webapi.Message{
327 MessageID: "missingltgt@localhost",
328 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
333 terrcode(t, err, "malformedMessageID")
335 // todo: messageLimitReached, recipientLimitReached
338 supListRes, err := client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
339 tcheckf(t, err, "listing suppressions")
340 tcompare(t, len(supListRes.Suppressions), 0)
343 supAddReq := webapi.SuppressionAddRequest{EmailAddress: "Remote.Last-catchall@xn--74h.localhost", Manual: true, Reason: "tests"}
344 _, err = client.SuppressionAdd(ctxbg, supAddReq)
345 tcheckf(t, err, "add address to suppression list")
346 _, err = client.SuppressionAdd(ctxbg, supAddReq)
347 terrcode(t, err, "error") // Already present.
348 supAddReq2 := webapi.SuppressionAddRequest{EmailAddress: "remotelast@☺.localhost", Manual: false, Reason: "tests"}
349 _, err = client.SuppressionAdd(ctxbg, supAddReq2)
350 terrcode(t, err, "error") // Already present, same base address.
351 supAddReq3 := webapi.SuppressionAddRequest{EmailAddress: "not an address"}
352 _, err = client.SuppressionAdd(ctxbg, supAddReq3)
353 terrcode(t, err, "badAddress")
355 supListRes, err = client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
356 tcheckf(t, err, "listing suppressions")
357 tcompare(t, len(supListRes.Suppressions), 1)
358 supListRes.Suppressions[0].Created = now
359 tcompare(t, supListRes.Suppressions, []webapi.Suppression{
364 BaseAddress: "remotelast@☺.localhost",
365 OriginalAddress: "Remote.Last-catchall@☺.localhost",
371 // SuppressionPresent
372 supPresRes, err := client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "not@localhost"})
373 tcheckf(t, err, "address present")
374 tcompare(t, supPresRes.Present, false)
375 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "remotelast@xn--74h.localhost"})
376 tcheckf(t, err, "address present")
377 tcompare(t, supPresRes.Present, true)
378 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "Remote.Last-catchall@☺.localhost"})
379 tcheckf(t, err, "address present")
380 tcompare(t, supPresRes.Present, true)
381 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "not an address"})
382 terrcode(t, err, "badAddress")
385 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "remote.LAST+more@☺.LocalHost"})
386 tcheckf(t, err, "remove suppressed address")
387 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "remote.LAST+more@☺.LocalHost"})
388 terrcode(t, err, "error") // Absent.
389 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "not an address"})
390 terrcode(t, err, "badAddress")
392 supListRes, err = client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
393 tcheckf(t, err, "listing suppressions")
394 tcompare(t, len(supListRes.Suppressions), 0)
396 // MessageGet, we retrieve the message we sent first.
397 msgRes, err := client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
398 tcheckf(t, err, "remove suppressed address")
399 sentMsg := sendReq.Message
400 sentMsg.Date = msgRes.Message.Date
402 tcompare(t, msgRes.Message, sentMsg)
403 // The structure is: mixed (related (alternative text html) inline-png) attached-pdf).
404 pdfpart := msgRes.Structure.Parts[1]
405 tcompare(t, pdfpart.ContentType, "application/pdf")
406 // structure compared below, parsed again from raw message.
407 // todo: compare Meta
409 _, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1 + 999})
410 terrcode(t, err, "messageNotFound")
413 r, err := client.MessageRawGet(ctxbg, webapi.MessageRawGetRequest{MsgID: 1})
414 tcheckf(t, err, "get raw message")
416 _, err = io.Copy(&b, r)
418 tcheckf(t, err, "reading raw message")
419 part, err := message.EnsurePart(log.Logger, true, bytes.NewReader(b.Bytes()), int64(b.Len()))
420 tcheckf(t, err, "parsing raw message")
421 tcompare(t, webhook.PartStructure(&part), msgRes.Structure)
423 _, err = client.MessageRawGet(ctxbg, webapi.MessageRawGetRequest{MsgID: 1 + 999})
424 terrcode(t, err, "messageNotFound")
427 // The structure is: mixed (related (alternative text html) inline-png) attached-pdf).
428 r, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{0, 0, 1}})
429 tcheckf(t, err, "get message part")
430 tdata(t, r, sendReq.HTML+"\r\n") // Part returns the raw data with \r\n line endings.
433 r, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{}})
434 tcheckf(t, err, "get message part")
437 _, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{2}})
438 terrcode(t, err, "partNotFound")
440 _, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1 + 999, PartPath: []int{}})
441 terrcode(t, err, "messageNotFound")
443 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1, Flags: []string{`\answered`, "$Forwarded", "custom"}})
444 tcheckf(t, err, "add flags")
446 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
447 tcheckf(t, err, "get message")
448 tcompare(t, slices.Contains(msgRes.Meta.Flags, `\answered`), true)
449 tcompare(t, slices.Contains(msgRes.Meta.Flags, "$forwarded"), true)
450 tcompare(t, slices.Contains(msgRes.Meta.Flags, "custom"), true)
452 // Setting duplicate flags doesn't make a change.
453 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
454 tcheckf(t, err, "add flags")
455 msgRes2, err := client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
456 tcheckf(t, err, "get message")
457 tcompare(t, msgRes.Meta.Flags, msgRes2.Meta.Flags)
459 // Non-existing message gives generic user error.
460 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1 + 999, Flags: []string{`\answered`, "$Forwarded", "custom"}})
461 terrcode(t, err, "messageNotFound")
463 // MessageFlagsRemove
464 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
465 tcheckf(t, err, "remove")
466 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
467 tcheckf(t, err, "get message")
468 tcompare(t, slices.Contains(msgRes.Meta.Flags, `\answered`), false)
469 tcompare(t, slices.Contains(msgRes.Meta.Flags, "$forwarded"), false)
470 tcompare(t, slices.Contains(msgRes.Meta.Flags, "custom"), false)
471 // Can try removing again, no change.
472 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
473 tcheckf(t, err, "remove")
475 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1 + 999, Flags: []string{`\Answered`, "$forwarded", "custom"}})
476 terrcode(t, err, "messageNotFound")
479 tcompare(t, msgRes.Meta.MailboxName, "Sent")
480 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1, DestMailboxName: "Inbox"})
481 tcheckf(t, err, "move to inbox")
482 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
483 tcheckf(t, err, "get message")
484 tcompare(t, msgRes.Meta.MailboxName, "Inbox")
485 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1, DestMailboxName: "Bogus"})
486 terrcode(t, err, "user")
487 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1 + 999, DestMailboxName: "Inbox"})
488 terrcode(t, err, "messageNotFound")
491 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1})
492 tcheckf(t, err, "delete message")
493 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1})
494 terrcode(t, err, "user") // No longer.
495 _, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
496 terrcode(t, err, "messageNotFound") // No longer.
497 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1 + 999})
498 terrcode(t, err, "messageNotFound")
501func tdata(t *testing.T, r io.Reader, exp string) {
503 buf, err := io.ReadAll(r)
504 tcheckf(t, err, "reading body")
505 tcompare(t, string(buf), exp)