1package webapisrv
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "mime/multipart"
11 "net/http"
12 "net/http/httptest"
13 "net/textproto"
14 "os"
15 "path/filepath"
16 "reflect"
17 "slices"
18 "strings"
19 "testing"
20 "time"
21
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)
29
30var ctxbg = context.Background()
31
32func tcheckf(t *testing.T, err error, format string, args ...any) {
33 t.Helper()
34 if err != nil {
35 t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
36 }
37}
38
39func tcompare(t *testing.T, got, expect any) {
40 t.Helper()
41 if !reflect.DeepEqual(got, expect) {
42 t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
43 }
44}
45
46func terrcode(t *testing.T, err error, code string) {
47 t.Helper()
48 if err == nil {
49 t.Fatalf("no error, expected error with code %q", code)
50 }
51 if xerr, ok := err.(webapi.Error); !ok {
52 t.Fatalf("got %v, expected webapi error with code %q", err, code)
53 } else if xerr.Code != code {
54 t.Fatalf("got error code %q, expected %q", xerr.Code, code)
55 }
56}
57
58func TestServer(t *testing.T) {
59 mox.LimitersInit()
60 os.RemoveAll("../testdata/webapisrv/data")
61 mox.Context = ctxbg
62 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webapisrv/mox.conf")
63 mox.MustLoadConfig(true, false)
64 defer store.Switchboard()()
65 err := queue.Init()
66 tcheckf(t, err, "queue init")
67 defer queue.Shutdown()
68
69 log := mlog.New("webapisrv", nil)
70 acc, err := store.OpenAccount(log, "mjl")
71 tcheckf(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 tcheckf(t, err, "set password")
76 defer func() {
77 err := acc.Close()
78 log.Check(err, "closing account")
79 acc.CheckClosed()
80 }()
81
82 s := NewServer(100*1024, "/webapi/", false).(server)
83 hs := httptest.NewServer(s)
84 defer hs.Close()
85
86 // server expects the mount path to be stripped already.
87 client := webapi.Client{BaseURL: hs.URL + "/v0/", Username: "mjl@mox.example", Password: pw0}
88
89 testHTTPHdrsBody := func(s server, method, path string, headers map[string]string, body string, expCode int, expTooMany bool, expCT, expErrCode string) {
90 t.Helper()
91
92 r := httptest.NewRequest(method, path, strings.NewReader(body))
93 for k, v := range headers {
94 r.Header.Set(k, v)
95 }
96 w := httptest.NewRecorder()
97 s.ServeHTTP(w, r)
98 res := w.Result()
99 if res.StatusCode != http.StatusTooManyRequests || !expTooMany {
100 tcompare(t, res.StatusCode, expCode)
101 }
102 if expCT != "" {
103 tcompare(t, res.Header.Get("Content-Type"), expCT)
104 }
105 if expErrCode != "" {
106 dec := json.NewDecoder(res.Body)
107 dec.DisallowUnknownFields()
108 var apierr webapi.Error
109 err := dec.Decode(&apierr)
110 tcheckf(t, err, "decoding json error")
111 tcompare(t, apierr.Code, expErrCode)
112 }
113 }
114 testHTTP := func(method, path string, expCode int, expCT string) {
115 t.Helper()
116 testHTTPHdrsBody(s, method, path, nil, "", expCode, false, expCT, "")
117 }
118
119 testHTTP("GET", "/", http.StatusSeeOther, "")
120 testHTTP("POST", "/", http.StatusMethodNotAllowed, "")
121 testHTTP("GET", "/v0/", http.StatusOK, "text/html; charset=utf-8")
122 testHTTP("GET", "/other/", http.StatusNotFound, "")
123 testHTTP("GET", "/v0/Send", http.StatusOK, "text/html; charset=utf-8")
124 testHTTP("GET", "/v0/MessageRawGet", http.StatusOK, "text/html; charset=utf-8")
125 testHTTP("GET", "/v0/Bogus", http.StatusNotFound, "")
126 testHTTP("PUT", "/v0/Send", http.StatusMethodNotAllowed, "")
127 testHTTP("POST", "/v0/Send", http.StatusUnauthorized, "")
128
129 for i := 0; i < 11; i++ {
130 // Missing auth doesn't trigger auth rate limiter.
131 testHTTP("POST", "/v0/Send", http.StatusUnauthorized, "")
132 }
133 for i := 0; i < 21; i++ {
134 // Bad auth does.
135 expCode := http.StatusUnauthorized
136 tooMany := i >= 10
137 if i == 20 {
138 expCode = http.StatusTooManyRequests
139 }
140 testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:badpassword"))}, "", expCode, tooMany, "", "")
141 }
142 mox.LimitersInit()
143
144 // Request with missing X-Forwarded-For.
145 sfwd := NewServer(100*1024, "/webapi/", true).(server)
146 testHTTPHdrsBody(sfwd, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:badpassword"))}, "", http.StatusInternalServerError, false, "", "")
147
148 // Body must be form, not JSON.
149 authz := "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:"+pw1))
150 testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Content-Type": "application/json", "Authorization": authz}, "{}", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
151 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")
152 formAuth := map[string]string{
153 "Content-Type": "application/x-www-form-urlencoded",
154 "Authorization": authz,
155 }
156 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "not encoded\n\n", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
157 // Missing "request".
158 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
159 // "request" must be JSON.
160 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "request=notjson", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
161 // "request" must be JSON object.
162 testHTTPHdrsBody(s, "POST", "/v0/Send", formAuth, "request=[]", http.StatusBadRequest, false, "application/json; charset=utf-8", "protocol")
163
164 // Send message. Look for the message in the queue.
165 now := time.Now()
166 yes := true
167 sendReq := webapi.SendRequest{
168 Message: webapi.Message{
169 From: []webapi.NameAddress{{Name: "møx", Address: "mjl@mox.example"}},
170 To: []webapi.NameAddress{{Name: "móx", Address: "mjl+to@mox.example"}, {Address: "mjl+to2@mox.example"}},
171 CC: []webapi.NameAddress{{Name: "möx", Address: "mjl+cc@mox.example"}},
172 BCC: []webapi.NameAddress{{Name: "møx", Address: "mjl+bcc@mox.example"}},
173 ReplyTo: []webapi.NameAddress{{Name: "reply1", Address: "mox+reply1@mox.example"}, {Name: "reply2", Address: "mox+reply2@mox.example"}},
174 MessageID: "<random@localhost>",
175 References: []string{"<messageid0@localhost>", "<messageid1@localhost>"},
176 Date: &now,
177 Subject: "¡hello world!",
178 Text: "hi ☺\n",
179 HTML: `<html><img src="cid:x" /></html>`, // Newline will be added.
180 },
181 Extra: map[string]string{"a": "123"},
182 Headers: [][2]string{{"x-custom", "header"}},
183 AlternativeFiles: []webapi.File{
184 {
185 Name: "x.ics",
186 ContentType: "text/calendar",
187 Data: base64.StdEncoding.EncodeToString([]byte("ics data...")),
188 },
189 },
190 InlineFiles: []webapi.File{
191 {
192 Name: "x.png",
193 ContentType: "image/png",
194 ContentID: "<x>",
195 Data: base64.StdEncoding.EncodeToString([]byte("png data")),
196 },
197 },
198 AttachedFiles: []webapi.File{
199 {
200 Data: base64.StdEncoding.EncodeToString([]byte("%PDF-")), // Should be detected as PDF.
201 },
202 },
203 RequireTLS: &yes,
204 FutureRelease: &now,
205 SaveSent: true,
206 }
207 sendResp, err := client.Send(ctxbg, sendReq)
208 tcheckf(t, err, "send message")
209 tcompare(t, sendResp.MessageID, sendReq.Message.MessageID)
210 tcompare(t, len(sendResp.Submissions), 2+1+1) // 2 to, 1 cc, 1 bcc
211 subs := sendResp.Submissions
212 tcompare(t, subs[0].Address, "mjl+to@mox.example")
213 tcompare(t, subs[1].Address, "mjl+to2@mox.example")
214 tcompare(t, subs[2].Address, "mjl+cc@mox.example")
215 tcompare(t, subs[3].Address, "mjl+bcc@mox.example")
216 tcompare(t, subs[3].QueueMsgID, subs[0].QueueMsgID+3)
217 tcompare(t, subs[0].FromID, "")
218 // todo: look in queue for parameters. parse the message.
219
220 // Send a custom multipart/form-data POST, with different request parameters, and
221 // additional files.
222 var sb strings.Builder
223 mp := multipart.NewWriter(&sb)
224 fdSendReq := webapi.SendRequest{
225 Message: webapi.Message{
226 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
227 // Let server assign date, message-id.
228 Subject: "test",
229 Text: "hi",
230 },
231 // Don't let server add its own user-agent.
232 Headers: [][2]string{{"User-Agent", "test"}},
233 }
234 sendReqBuf, err := json.Marshal(fdSendReq)
235 tcheckf(t, err, "send request")
236 mp.WriteField("request", string(sendReqBuf))
237
238 // One alternative file.
239 pw, err := mp.CreateFormFile("alternativefile", "test.ics")
240 tcheckf(t, err, "create alternative ics file")
241 _, err = fmt.Fprint(pw, "ICS...")
242 tcheckf(t, err, "write ics")
243
244 // Two inline PDFs.
245 pw, err = mp.CreateFormFile("inlinefile", "test.pdf")
246 tcheckf(t, err, "create inline pdf file")
247 _, err = fmt.Fprint(pw, "%PDF-")
248 tcheckf(t, err, "write pdf")
249 pw, err = mp.CreateFormFile("inlinefile", "test.pdf")
250 tcheckf(t, err, "create second inline pdf file")
251 _, err = fmt.Fprint(pw, "%PDF-")
252 tcheckf(t, err, "write second pdf")
253
254 // One attached PDF.
255 fh := textproto.MIMEHeader{}
256 fh.Set("Content-Disposition", `form-data; name="attachedfile"; filename="test.pdf"`)
257 fh.Set("Content-ID", "<testpdf>")
258 pw, err = mp.CreatePart(fh)
259 tcheckf(t, err, "create attached pdf file")
260 _, err = fmt.Fprint(pw, "%PDF-")
261 tcheckf(t, err, "write attached pdf")
262 fdct := mp.FormDataContentType()
263 err = mp.Close()
264 tcheckf(t, err, "close multipart")
265
266 // Perform custom POST.
267 req, err := http.NewRequest("POST", hs.URL+"/v0/Send", strings.NewReader(sb.String()))
268 tcheckf(t, err, "new request")
269 req.Header.Set("Content-Type", fdct)
270 // Use a unique MAIL FROM id when delivering.
271 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("mjl+fromid@mox.example:"+pw1)))
272 resp, err := http.DefaultClient.Do(req)
273 tcheckf(t, err, "request multipart/form-data")
274 tcompare(t, resp.StatusCode, http.StatusOK)
275 var sendRes webapi.SendResult
276 err = json.NewDecoder(resp.Body).Decode(&sendRes)
277 tcheckf(t, err, "parse send response")
278 tcompare(t, sendRes.MessageID != "", true)
279 tcompare(t, len(sendRes.Submissions), 1)
280 tcompare(t, sendRes.Submissions[0].FromID != "", true)
281
282 // Trigger various error conditions.
283 _, err = client.Send(ctxbg, webapi.SendRequest{
284 Message: webapi.Message{
285 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
286 Subject: "test",
287 },
288 })
289 terrcode(t, err, "missingBody")
290
291 _, err = client.Send(ctxbg, webapi.SendRequest{
292 Message: webapi.Message{
293 From: []webapi.NameAddress{{Address: "other@mox.example"}},
294 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
295 Subject: "test",
296 Text: "hi",
297 },
298 })
299 terrcode(t, err, "badFrom")
300
301 _, err = client.Send(ctxbg, webapi.SendRequest{
302 Message: webapi.Message{
303 From: []webapi.NameAddress{{Address: "mox@mox.example"}, {Address: "mox@mox.example"}},
304 To: []webapi.NameAddress{{Address: "mjl@mox.example"}},
305 Subject: "test",
306 Text: "hi",
307 },
308 })
309 terrcode(t, err, "multipleFrom")
310
311 _, err = client.Send(ctxbg, webapi.SendRequest{Message: webapi.Message{Subject: "test", Text: "hi"}})
312 terrcode(t, err, "noRecipients")
313
314 _, err = client.Send(ctxbg, webapi.SendRequest{
315 Message: webapi.Message{
316 MessageID: "missingltgt@localhost",
317 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
318 Subject: "test",
319 Text: "hi",
320 },
321 })
322 terrcode(t, err, "malformedMessageID")
323
324 _, err = client.Send(ctxbg, webapi.SendRequest{
325 Message: webapi.Message{
326 MessageID: "missingltgt@localhost",
327 To: []webapi.NameAddress{{Address: "møx@mox.example"}},
328 Subject: "test",
329 Text: "hi",
330 },
331 })
332 terrcode(t, err, "malformedMessageID")
333
334 // todo: messageLimitReached, recipientLimitReached
335
336 // SuppressionList
337 supListRes, err := client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
338 tcheckf(t, err, "listing suppressions")
339 tcompare(t, len(supListRes.Suppressions), 0)
340
341 // SuppressionAdd
342 supAddReq := webapi.SuppressionAddRequest{EmailAddress: "Remote.Last-catchall@xn--74h.localhost", Manual: true, Reason: "tests"}
343 _, err = client.SuppressionAdd(ctxbg, supAddReq)
344 tcheckf(t, err, "add address to suppression list")
345 _, err = client.SuppressionAdd(ctxbg, supAddReq)
346 terrcode(t, err, "error") // Already present.
347 supAddReq2 := webapi.SuppressionAddRequest{EmailAddress: "remotelast@☺.localhost", Manual: false, Reason: "tests"}
348 _, err = client.SuppressionAdd(ctxbg, supAddReq2)
349 terrcode(t, err, "error") // Already present, same base address.
350 supAddReq3 := webapi.SuppressionAddRequest{EmailAddress: "not an address"}
351 _, err = client.SuppressionAdd(ctxbg, supAddReq3)
352 terrcode(t, err, "badAddress")
353
354 supListRes, err = client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
355 tcheckf(t, err, "listing suppressions")
356 tcompare(t, len(supListRes.Suppressions), 1)
357 supListRes.Suppressions[0].Created = now
358 tcompare(t, supListRes.Suppressions, []webapi.Suppression{
359 {
360 ID: 1,
361 Created: now,
362 Account: "mjl",
363 BaseAddress: "remotelast@☺.localhost",
364 OriginalAddress: "Remote.Last-catchall@☺.localhost",
365 Manual: true,
366 Reason: "tests",
367 },
368 })
369
370 // SuppressionPresent
371 supPresRes, err := client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "not@localhost"})
372 tcheckf(t, err, "address present")
373 tcompare(t, supPresRes.Present, false)
374 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "remotelast@xn--74h.localhost"})
375 tcheckf(t, err, "address present")
376 tcompare(t, supPresRes.Present, true)
377 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "Remote.Last-catchall@☺.localhost"})
378 tcheckf(t, err, "address present")
379 tcompare(t, supPresRes.Present, true)
380 supPresRes, err = client.SuppressionPresent(ctxbg, webapi.SuppressionPresentRequest{EmailAddress: "not an address"})
381 terrcode(t, err, "badAddress")
382
383 // SuppressionRemove
384 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "remote.LAST+more@☺.LocalHost"})
385 tcheckf(t, err, "remove suppressed address")
386 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "remote.LAST+more@☺.LocalHost"})
387 terrcode(t, err, "error") // Absent.
388 _, err = client.SuppressionRemove(ctxbg, webapi.SuppressionRemoveRequest{EmailAddress: "not an address"})
389 terrcode(t, err, "badAddress")
390
391 supListRes, err = client.SuppressionList(ctxbg, webapi.SuppressionListRequest{})
392 tcheckf(t, err, "listing suppressions")
393 tcompare(t, len(supListRes.Suppressions), 0)
394
395 // MessageGet, we retrieve the message we sent first.
396 msgRes, err := client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
397 tcheckf(t, err, "remove suppressed address")
398 sentMsg := sendReq.Message
399 sentMsg.Date = msgRes.Message.Date
400 sentMsg.HTML += "\n"
401 tcompare(t, msgRes.Message, sentMsg)
402 // The structure is: mixed (related (alternative text html) inline-png) attached-pdf).
403 pdfpart := msgRes.Structure.Parts[1]
404 tcompare(t, pdfpart.ContentType, "application/pdf")
405 // structure compared below, parsed again from raw message.
406 // todo: compare Meta
407
408 _, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1 + 999})
409 terrcode(t, err, "messageNotFound")
410
411 // MessageRawGet
412 r, err := client.MessageRawGet(ctxbg, webapi.MessageRawGetRequest{MsgID: 1})
413 tcheckf(t, err, "get raw message")
414 var b bytes.Buffer
415 _, err = io.Copy(&b, r)
416 r.Close()
417 tcheckf(t, err, "reading raw message")
418 part, err := message.EnsurePart(log.Logger, true, bytes.NewReader(b.Bytes()), int64(b.Len()))
419 tcheckf(t, err, "parsing raw message")
420 structure, err := queue.PartStructure(log, &part)
421 tcheckf(t, err, "part structure")
422 tcompare(t, structure, msgRes.Structure)
423
424 _, err = client.MessageRawGet(ctxbg, webapi.MessageRawGetRequest{MsgID: 1 + 999})
425 terrcode(t, err, "messageNotFound")
426
427 // MessagePartGet
428 // The structure is: mixed (related (alternative text html) inline-png) attached-pdf).
429 r, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{0, 0, 1}})
430 tcheckf(t, err, "get message part")
431 tdata(t, r, sendReq.HTML+"\r\n") // Part returns the raw data with \r\n line endings.
432 r.Close()
433
434 r, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{}})
435 tcheckf(t, err, "get message part")
436 r.Close()
437
438 _, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1, PartPath: []int{2}})
439 terrcode(t, err, "partNotFound")
440
441 _, err = client.MessagePartGet(ctxbg, webapi.MessagePartGetRequest{MsgID: 1 + 999, PartPath: []int{}})
442 terrcode(t, err, "messageNotFound")
443
444 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1, Flags: []string{`\answered`, "$Forwarded", "custom"}})
445 tcheckf(t, err, "add flags")
446
447 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
448 tcheckf(t, err, "get message")
449 tcompare(t, slices.Contains(msgRes.Meta.Flags, `\answered`), true)
450 tcompare(t, slices.Contains(msgRes.Meta.Flags, "$forwarded"), true)
451 tcompare(t, slices.Contains(msgRes.Meta.Flags, "custom"), true)
452
453 // Setting duplicate flags doesn't make a change.
454 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
455 tcheckf(t, err, "add flags")
456 msgRes2, err := client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
457 tcheckf(t, err, "get message")
458 tcompare(t, msgRes.Meta.Flags, msgRes2.Meta.Flags)
459
460 // Non-existing message gives generic user error.
461 _, err = client.MessageFlagsAdd(ctxbg, webapi.MessageFlagsAddRequest{MsgID: 1 + 999, Flags: []string{`\answered`, "$Forwarded", "custom"}})
462 terrcode(t, err, "messageNotFound")
463
464 // MessageFlagsRemove
465 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
466 tcheckf(t, err, "remove")
467 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
468 tcheckf(t, err, "get message")
469 tcompare(t, slices.Contains(msgRes.Meta.Flags, `\answered`), false)
470 tcompare(t, slices.Contains(msgRes.Meta.Flags, "$forwarded"), false)
471 tcompare(t, slices.Contains(msgRes.Meta.Flags, "custom"), false)
472 // Can try removing again, no change.
473 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1, Flags: []string{`\Answered`, "$forwarded", "custom"}})
474 tcheckf(t, err, "remove")
475
476 _, err = client.MessageFlagsRemove(ctxbg, webapi.MessageFlagsRemoveRequest{MsgID: 1 + 999, Flags: []string{`\Answered`, "$forwarded", "custom"}})
477 terrcode(t, err, "messageNotFound")
478
479 // MessageMove
480 tcompare(t, msgRes.Meta.MailboxName, "Sent")
481 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1, DestMailboxName: "Inbox"})
482 tcheckf(t, err, "move to inbox")
483 msgRes, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
484 tcheckf(t, err, "get message")
485 tcompare(t, msgRes.Meta.MailboxName, "Inbox")
486 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1, DestMailboxName: "Bogus"})
487 terrcode(t, err, "user")
488 _, err = client.MessageMove(ctxbg, webapi.MessageMoveRequest{MsgID: 1 + 999, DestMailboxName: "Inbox"})
489 terrcode(t, err, "messageNotFound")
490
491 // MessageDelete
492 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1})
493 tcheckf(t, err, "delete message")
494 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1})
495 terrcode(t, err, "user") // No longer.
496 _, err = client.MessageGet(ctxbg, webapi.MessageGetRequest{MsgID: 1})
497 terrcode(t, err, "messageNotFound") // No longer.
498 _, err = client.MessageDelete(ctxbg, webapi.MessageDeleteRequest{MsgID: 1 + 999})
499 terrcode(t, err, "messageNotFound")
500}
501
502func tdata(t *testing.T, r io.Reader, exp string) {
503 t.Helper()
504 buf, err := io.ReadAll(r)
505 tcheckf(t, err, "reading body")
506 tcompare(t, string(buf), exp)
507}
508