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