1package webmail
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "path/filepath"
16 "reflect"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/mjl-/mox/mlog"
22 "github.com/mjl-/mox/mox-"
23 "github.com/mjl-/mox/store"
24)
25
26func TestView(t *testing.T) {
27 mox.LimitersInit()
28 os.RemoveAll("../testdata/webmail/data")
29 mox.Context = ctxbg
30 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
31 mox.MustLoadConfig(true, false)
32 defer store.Switchboard()()
33 err := store.Init(ctxbg)
34 tcheck(t, err, "store init")
35 defer func() {
36 err := store.Close()
37 tcheck(t, err, "store close")
38 }()
39
40 log := mlog.New("webmail", nil)
41 acc, err := store.OpenAccount(log, "mjl", false)
42 tcheck(t, err, "open account")
43 err = acc.SetPassword(log, "test1234")
44 tcheck(t, err, "set password")
45 defer func() {
46 err := acc.Close()
47 pkglog.Check(err, "closing account")
48 acc.CheckClosed()
49 }()
50
51 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/"}
52
53 respRec := httptest.NewRecorder()
54 reqInfo := requestInfo{log, "mjl@mox.example", acc, "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
55 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
56
57 // Prepare loginToken.
58 loginCookie := &http.Cookie{Name: "webmaillogin"}
59 loginCookie.Value = api.LoginPrep(ctx)
60 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
61
62 api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
63 var sessionCookie *http.Cookie
64 for _, c := range respRec.Result().Cookies() {
65 if c.Name == "webmailsession" {
66 sessionCookie = c
67 break
68 }
69 }
70 if sessionCookie == nil {
71 t.Fatalf("missing session cookie")
72 }
73 sct := strings.SplitN(sessionCookie.Value, " ", 2)
74 if len(sct) != 2 || sct[1] != "mjl" {
75 t.Fatalf("unexpected accountname %q in session cookie", sct[1])
76 }
77 sessionToken := store.SessionToken(sct[0])
78
79 reqInfo = requestInfo{log, "mjl@mox.example", acc, sessionToken, respRec, &http.Request{}}
80 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
81
82 api.MailboxCreate(ctx, "Lists/Go/Nuts")
83
84 var zerom store.Message
85 var (
86 inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
87 inboxFlags = &testmsg{"Inbox", store.Flags{Seen: true}, []string{"testlabel"}, msgAltRel, zerom, 0} // With flags, and larger.
88 listsMinimal = &testmsg{"Lists", store.Flags{}, nil, msgMinimal, zerom, 0}
89 listsGoNutsMinimal = &testmsg{"Lists/Go/Nuts", store.Flags{}, nil, msgMinimal, zerom, 0}
90 trashMinimal = &testmsg{"Trash", store.Flags{}, nil, msgMinimal, zerom, 0}
91 junkMinimal = &testmsg{"Trash", store.Flags{}, nil, msgMinimal, zerom, 0}
92 trashAlt = &testmsg{"Trash", store.Flags{}, nil, msgAlt, zerom, 0}
93 inboxAltReply = &testmsg{"Inbox", store.Flags{}, nil, msgAltReply, zerom, 0}
94 )
95 var testmsgs = []*testmsg{inboxMinimal, inboxFlags, listsMinimal, listsGoNutsMinimal, trashMinimal, junkMinimal, trashAlt, inboxAltReply}
96 for _, tm := range testmsgs {
97 tdeliver(t, acc, tm)
98 }
99
100 // Token
101 tokens := []string{}
102 for i := 0; i < 20; i++ {
103 tokens = append(tokens, api.Token(ctx))
104 }
105 // Only last 10 tokens are still valid and around, checked below.
106
107 // Request
108 tneedError(t, func() { api.Request(ctx, Request{ID: 1, Cancel: true}) }) // Zero/invalid SSEID.
109
110 // We start an actual HTTP server to easily get a body we can do blocking reads on.
111 // With a httptest.ResponseRecorder, it's a bit more work to parse SSE events as
112 // they come in.
113 server := httptest.NewServer(http.HandlerFunc(Handler(1024*1024, "/webmail/", false, "")))
114 defer server.Close()
115
116 serverURL, err := url.Parse(server.URL)
117 tcheck(t, err, "parsing server url")
118 _, port, err := net.SplitHostPort(serverURL.Host)
119 tcheck(t, err, "parsing host port in server url")
120 eventsURL := fmt.Sprintf("http://%s/events", net.JoinHostPort("localhost", port))
121
122 request := Request{
123 Page: Page{Count: 10},
124 }
125 requestJSON, err := json.Marshal(request)
126 tcheck(t, err, "marshal request as json")
127
128 testFail := func(method, path string, expStatusCode int) {
129 t.Helper()
130 req, err := http.NewRequest(method, path, nil)
131 tcheck(t, err, "making request")
132 resp, err := http.DefaultClient.Do(req)
133 tcheck(t, err, "http transaction")
134 resp.Body.Close()
135 if resp.StatusCode != expStatusCode {
136 t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expStatusCode)
137 }
138 }
139
140 testFail("POST", eventsURL+"?singleUseToken="+tokens[0]+"&request="+string(requestJSON), http.StatusMethodNotAllowed) // Must be GET.
141 testFail("GET", eventsURL, http.StatusBadRequest) // Missing token.
142 testFail("GET", eventsURL+"?singleUseToken="+tokens[0]+"&request="+string(requestJSON), http.StatusBadRequest) // Bad (old) token.
143 testFail("GET", eventsURL+"?singleUseToken="+tokens[len(tokens)-5]+"&request=bad", http.StatusBadRequest) // Bad request.
144
145 // Start connection for testing and filters below.
146 req, err := http.NewRequest("GET", eventsURL+"?singleUseToken="+tokens[len(tokens)-1]+"&request="+string(requestJSON), nil)
147 tcheck(t, err, "making request")
148 resp, err := http.DefaultClient.Do(req)
149 tcheck(t, err, "http transaction")
150 defer resp.Body.Close()
151 if resp.StatusCode != http.StatusOK {
152 t.Fatalf("got statuscode %d, expected %d (%s)", resp.StatusCode, http.StatusOK, readBody(resp.Body))
153 }
154
155 evr := eventReader{t, bufio.NewReader(resp.Body), resp.Body}
156 var start EventStart
157 evr.Get("start", &start)
158 var viewMsgs EventViewMsgs
159 evr.Get("viewMsgs", &viewMsgs)
160 tcompare(t, len(viewMsgs.MessageItems), 3)
161 tcompare(t, viewMsgs.ViewEnd, true)
162
163 var inbox, archive, lists, trash store.Mailbox
164 for _, mb := range start.Mailboxes {
165 if mb.Archive {
166 archive = mb
167 } else if mb.Name == start.MailboxName {
168 inbox = mb
169 } else if mb.Name == "Lists" {
170 lists = mb
171 } else if mb.Name == "Trash" {
172 trash = mb
173 }
174 }
175
176 // Can only use a token once.
177 testFail("GET", eventsURL+"?singleUseToken="+tokens[len(tokens)-1]+"&request=bad", http.StatusBadRequest)
178
179 // Check a few initial query/page combinations.
180 testConn := func(token, more string, request Request, check func(EventStart, eventReader)) {
181 t.Helper()
182
183 reqJSON, err := json.Marshal(request)
184 tcheck(t, err, "marshal request json")
185 req, err := http.NewRequest("GET", eventsURL+"?singleUseToken="+token+more+"&request="+string(reqJSON), nil)
186 tcheck(t, err, "making request")
187 resp, err := http.DefaultClient.Do(req)
188 tcheck(t, err, "http transaction")
189 defer resp.Body.Close()
190 if resp.StatusCode != http.StatusOK {
191 t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, http.StatusOK)
192 }
193
194 xevr := eventReader{t, bufio.NewReader(resp.Body), resp.Body}
195 var xstart EventStart
196 xevr.Get("start", &xstart)
197 check(start, xevr)
198 }
199
200 // Connection with waitMinMsec/waitMaxMsec, just exercising code path.
201 waitReq := Request{
202 Page: Page{Count: 10},
203 }
204 testConn(api.Token(ctx), "&waitMinMsec=1&waitMaxMsec=2", waitReq, func(start EventStart, evr eventReader) {
205 var vm EventViewMsgs
206 evr.Get("viewMsgs", &vm)
207 tcompare(t, len(vm.MessageItems), 3)
208 })
209
210 // Connection with DestMessageID.
211 destMsgReq := Request{
212 Query: Query{
213 Filter: Filter{MailboxID: inbox.ID},
214 },
215 Page: Page{DestMessageID: inboxFlags.ID, Count: 10},
216 }
217 testConn(tokens[len(tokens)-3], "", destMsgReq, func(start EventStart, evr eventReader) {
218 var vm EventViewMsgs
219 evr.Get("viewMsgs", &vm)
220 tcompare(t, len(vm.MessageItems), 3)
221 tcompare(t, vm.ParsedMessage.ID, destMsgReq.Page.DestMessageID)
222 })
223 // todo: destmessageid past count, needs large mailbox
224
225 // Connection with missing DestMessageID, still fine.
226 badDestMsgReq := Request{
227 Query: Query{
228 Filter: Filter{MailboxID: inbox.ID},
229 },
230 Page: Page{DestMessageID: inboxFlags.ID + 999, Count: 10},
231 }
232 testConn(api.Token(ctx), "", badDestMsgReq, func(start EventStart, evr eventReader) {
233 var vm EventViewMsgs
234 evr.Get("viewMsgs", &vm)
235 tcompare(t, len(vm.MessageItems), 3)
236 })
237
238 // Connection with missing unknown AnchorMessageID, resets view.
239 badAnchorMsgReq := Request{
240 Query: Query{
241 Filter: Filter{MailboxID: inbox.ID},
242 },
243 Page: Page{AnchorMessageID: inboxFlags.ID + 999, Count: 10},
244 }
245 testConn(api.Token(ctx), "", badAnchorMsgReq, func(start EventStart, evr eventReader) {
246 var viewReset EventViewReset
247 evr.Get("viewReset", &viewReset)
248
249 var vm EventViewMsgs
250 evr.Get("viewMsgs", &vm)
251 tcompare(t, len(vm.MessageItems), 3)
252 })
253
254 // Connection that starts with a filter, without mailbox.
255 searchReq := Request{
256 Query: Query{
257 Filter: Filter{Labels: []string{`\seen`}},
258 },
259 Page: Page{Count: 10},
260 }
261 testConn(api.Token(ctx), "", searchReq, func(start EventStart, evr eventReader) {
262 var vm EventViewMsgs
263 evr.Get("viewMsgs", &vm)
264 tcompare(t, len(vm.MessageItems), 1)
265 tcompare(t, vm.MessageItems[0][0].Message.ID, inboxFlags.ID)
266 })
267
268 // Paginate from previous last element. There is nothing new.
269 var viewID int64 = 1
270 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: inbox.ID}}, Page: Page{Count: 10, AnchorMessageID: viewMsgs.MessageItems[len(viewMsgs.MessageItems)-1][0].Message.ID}})
271 evr.Get("viewMsgs", &viewMsgs)
272 tcompare(t, len(viewMsgs.MessageItems), 0)
273
274 // Request archive mailbox, empty.
275 viewID++
276 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: archive.ID}}, Page: Page{Count: 10}})
277 evr.Get("viewMsgs", &viewMsgs)
278 tcompare(t, len(viewMsgs.MessageItems), 0)
279 tcompare(t, viewMsgs.ViewEnd, true)
280
281 threadlen := func(mil [][]MessageItem) int {
282 n := 0
283 for _, l := range mil {
284 n += len(l)
285 }
286 return n
287 }
288
289 // Request with threading, should also include parent message from Trash mailbox (trashAlt).
290 viewID++
291 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: inbox.ID}, Threading: "unread"}, Page: Page{Count: 10}})
292 evr.Get("viewMsgs", &viewMsgs)
293 tcompare(t, len(viewMsgs.MessageItems), 3)
294 tcompare(t, threadlen(viewMsgs.MessageItems), 3+1)
295 tcompare(t, viewMsgs.ViewEnd, true)
296 // And likewise when querying Trash, should also include child message in Inbox (inboxAltReply).
297 viewID++
298 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: trash.ID}, Threading: "on"}, Page: Page{Count: 10}})
299 evr.Get("viewMsgs", &viewMsgs)
300 tcompare(t, len(viewMsgs.MessageItems), 3)
301 tcompare(t, threadlen(viewMsgs.MessageItems), 3+1)
302 tcompare(t, viewMsgs.ViewEnd, true)
303 // Without threading, the inbox has just 3 messages.
304 viewID++
305 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{Filter: Filter{MailboxID: inbox.ID}, Threading: "off"}, Page: Page{Count: 10}})
306 evr.Get("viewMsgs", &viewMsgs)
307 tcompare(t, len(viewMsgs.MessageItems), 3)
308 tcompare(t, threadlen(viewMsgs.MessageItems), 3)
309 tcompare(t, viewMsgs.ViewEnd, true)
310
311 testFilter := func(orderAsc bool, f Filter, nf NotFilter, expIDs []int64) {
312 t.Helper()
313 viewID++
314 api.Request(ctx, Request{ID: 1, SSEID: start.SSEID, ViewID: viewID, Query: Query{OrderAsc: orderAsc, Filter: f, NotFilter: nf}, Page: Page{Count: 10}})
315 evr.Get("viewMsgs", &viewMsgs)
316 ids := make([]int64, len(viewMsgs.MessageItems))
317 for i, mi := range viewMsgs.MessageItems {
318 ids[i] = mi[0].Message.ID
319 }
320 tcompare(t, ids, expIDs)
321 tcompare(t, viewMsgs.ViewEnd, true)
322 }
323
324 // Test filtering.
325 var znf NotFilter
326 testFilter(false, Filter{MailboxID: lists.ID, MailboxChildrenIncluded: true}, znf, []int64{listsGoNutsMinimal.ID, listsMinimal.ID}) // Mailbox and sub mailbox.
327 testFilter(true, Filter{MailboxID: lists.ID, MailboxChildrenIncluded: true}, znf, []int64{listsMinimal.ID, listsGoNutsMinimal.ID}) // Oldest first first.
328 testFilter(false, Filter{MailboxID: -1}, znf, []int64{inboxAltReply.ID, listsGoNutsMinimal.ID, listsMinimal.ID, inboxFlags.ID, inboxMinimal.ID}) // All except trash/junk/rejects.
329 testFilter(false, Filter{Labels: []string{`\seen`}}, znf, []int64{inboxFlags.ID})
330 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Labels: []string{`\seen`}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
331 testFilter(false, Filter{Labels: []string{`testlabel`}}, znf, []int64{inboxFlags.ID})
332 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Labels: []string{`testlabel`}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
333 testFilter(false, Filter{MailboxID: inbox.ID, Oldest: &inboxFlags.m.Received}, znf, []int64{inboxAltReply.ID, inboxFlags.ID})
334 testFilter(false, Filter{MailboxID: inbox.ID, Newest: &inboxMinimal.m.Received}, znf, []int64{inboxMinimal.ID})
335 testFilter(false, Filter{MailboxID: inbox.ID, SizeMin: inboxFlags.m.Size}, znf, []int64{inboxFlags.ID})
336 testFilter(false, Filter{MailboxID: inbox.ID, SizeMax: inboxMinimal.m.Size}, znf, []int64{inboxMinimal.ID})
337 testFilter(false, Filter{From: []string{"mjl+altrel@mox.example"}}, znf, []int64{inboxFlags.ID})
338 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{From: []string{"mjl+altrel@mox.example"}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
339 testFilter(false, Filter{To: []string{"mox+altrel@other.example"}}, znf, []int64{inboxFlags.ID})
340 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{To: []string{"mox+altrel@other.example"}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
341 testFilter(false, Filter{From: []string{"mjl+altrel@mox.example", "bogus"}}, znf, []int64{})
342 testFilter(false, Filter{To: []string{"mox+altrel@other.example", "bogus"}}, znf, []int64{})
343 testFilter(false, Filter{Subject: []string{"test", "alt", "rel"}}, znf, []int64{inboxFlags.ID})
344 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Subject: []string{"alt"}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
345 testFilter(false, Filter{MailboxID: inbox.ID, Words: []string{"the text body", "body", "the "}}, znf, []int64{inboxFlags.ID})
346 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Words: []string{"the text body"}}, []int64{inboxAltReply.ID, inboxMinimal.ID})
347 testFilter(false, Filter{Headers: [][2]string{{"X-Special", ""}}}, znf, []int64{inboxFlags.ID})
348 testFilter(false, Filter{Headers: [][2]string{{"X-Special", "testing"}}}, znf, []int64{inboxFlags.ID})
349 testFilter(false, Filter{Headers: [][2]string{{"X-Special", "other"}}}, znf, []int64{})
350 testFilter(false, Filter{Attachments: AttachmentImage}, znf, []int64{inboxFlags.ID})
351 testFilter(false, Filter{MailboxID: inbox.ID}, NotFilter{Attachments: AttachmentImage}, []int64{inboxAltReply.ID, inboxMinimal.ID})
352
353 // Test changes.
354 getChanges := func(changes ...any) {
355 t.Helper()
356 var viewChanges EventViewChanges
357 evr.Get("viewChanges", &viewChanges)
358 if len(viewChanges.Changes) != len(changes) {
359 t.Fatalf("got %d changes, expected %d", len(viewChanges.Changes), len(changes))
360 }
361 for i, dst := range changes {
362 src := viewChanges.Changes[i]
363 dstType := reflect.TypeOf(dst).Elem().Name()
364 if src[0] != dstType {
365 t.Fatalf("change %d is of type %s, expected %s", i, src[0], dstType)
366 }
367 // Marshal and unmarshal is easiest...
368 buf, err := json.Marshal(src[1])
369 tcheck(t, err, "marshal change")
370 dec := json.NewDecoder(bytes.NewReader(buf))
371 dec.DisallowUnknownFields()
372 err = dec.Decode(dst)
373 tcheck(t, err, "parsing change")
374 }
375 }
376
377 // ChangeMailboxAdd
378 api.MailboxCreate(ctx, "Newbox")
379 var chmbadd ChangeMailboxAdd
380 getChanges(&chmbadd)
381 tcompare(t, chmbadd.Mailbox.Name, "Newbox")
382
383 // ChangeMailboxRename
384 api.MailboxRename(ctx, chmbadd.Mailbox.ID, "Newbox2")
385 var chmbrename ChangeMailboxRename
386 getChanges(&chmbrename)
387 tcompare(t, chmbrename, ChangeMailboxRename{
388 ChangeRenameMailbox: store.ChangeRenameMailbox{MailboxID: chmbadd.Mailbox.ID, OldName: "Newbox", NewName: "Newbox2", Flags: nil},
389 })
390
391 // ChangeMailboxSpecialUse
392 api.MailboxSetSpecialUse(ctx, store.Mailbox{ID: chmbadd.Mailbox.ID, SpecialUse: store.SpecialUse{Archive: true}})
393 var chmbspecialuseOld, chmbspecialuseNew ChangeMailboxSpecialUse
394 getChanges(&chmbspecialuseOld, &chmbspecialuseNew)
395 tcompare(t, chmbspecialuseOld, ChangeMailboxSpecialUse{
396 ChangeMailboxSpecialUse: store.ChangeMailboxSpecialUse{MailboxID: archive.ID, MailboxName: "Archive", SpecialUse: store.SpecialUse{}},
397 })
398 tcompare(t, chmbspecialuseNew, ChangeMailboxSpecialUse{
399 ChangeMailboxSpecialUse: store.ChangeMailboxSpecialUse{MailboxID: chmbadd.Mailbox.ID, MailboxName: "Newbox2", SpecialUse: store.SpecialUse{Archive: true}},
400 })
401
402 // ChangeMailboxRemove
403 api.MailboxDelete(ctx, chmbadd.Mailbox.ID)
404 var chmbremove ChangeMailboxRemove
405 getChanges(&chmbremove)
406 tcompare(t, chmbremove, ChangeMailboxRemove{
407 ChangeRemoveMailbox: store.ChangeRemoveMailbox{MailboxID: chmbadd.Mailbox.ID, Name: "Newbox2"},
408 })
409
410 // ChangeMsgAdd
411 inboxNew := &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
412 tdeliver(t, acc, inboxNew)
413 var chmsgadd ChangeMsgAdd
414 var chmbcounts ChangeMailboxCounts
415 getChanges(&chmsgadd, &chmbcounts)
416 tcompare(t, chmsgadd.ChangeAddUID.MailboxID, inbox.ID)
417 tcompare(t, chmsgadd.MessageItems[0].Message.ID, inboxNew.ID)
418 chmbcounts.Size = 0
419 tcompare(t, chmbcounts, ChangeMailboxCounts{
420 ChangeMailboxCounts: store.ChangeMailboxCounts{
421 MailboxID: inbox.ID,
422 MailboxName: inbox.Name,
423 MailboxCounts: store.MailboxCounts{Total: 4, Unread: 3, Unseen: 3},
424 },
425 })
426
427 // ChangeMsgFlags
428 api.FlagsAdd(ctx, []int64{inboxNew.ID}, []string{`\seen`, `changelabel`, `aaa`})
429 var chmsgflags ChangeMsgFlags
430 var chmbkeywords ChangeMailboxKeywords
431 getChanges(&chmsgflags, &chmbcounts, &chmbkeywords)
432 tcompare(t, chmsgadd.ChangeAddUID.MailboxID, inbox.ID)
433 tcompare(t, chmbkeywords, ChangeMailboxKeywords{
434 ChangeMailboxKeywords: store.ChangeMailboxKeywords{
435 MailboxID: inbox.ID,
436 MailboxName: inbox.Name,
437 Keywords: []string{`aaa`, `changelabel`},
438 },
439 })
440 chmbcounts.Size = 0
441 tcompare(t, chmbcounts, ChangeMailboxCounts{
442 ChangeMailboxCounts: store.ChangeMailboxCounts{
443 MailboxID: inbox.ID,
444 MailboxName: inbox.Name,
445 MailboxCounts: store.MailboxCounts{Total: 4, Unread: 2, Unseen: 2},
446 },
447 })
448
449 // ChangeMsgRemove
450 api.MessageDelete(ctx, []int64{inboxNew.ID, inboxMinimal.ID})
451 var chmsgremove ChangeMsgRemove
452 getChanges(&chmbcounts, &chmsgremove)
453 tcompare(t, chmsgremove.ChangeRemoveUIDs.MailboxID, inbox.ID)
454 tcompare(t, chmsgremove.ChangeRemoveUIDs.UIDs, []store.UID{inboxMinimal.m.UID, inboxNew.m.UID})
455 chmbcounts.Size = 0
456 tcompare(t, chmbcounts, ChangeMailboxCounts{
457 ChangeMailboxCounts: store.ChangeMailboxCounts{
458 MailboxID: inbox.ID,
459 MailboxName: inbox.Name,
460 MailboxCounts: store.MailboxCounts{Total: 2, Unread: 1, Unseen: 1},
461 },
462 })
463
464 // ChangeMsgThread
465 api.ThreadCollapse(ctx, []int64{inboxAltReply.ID}, true)
466 var chmsgthread ChangeMsgThread
467 getChanges(&chmsgthread)
468 tcompare(t, chmsgthread.ChangeThread, store.ChangeThread{MessageIDs: []int64{inboxAltReply.ID}, Muted: false, Collapsed: true})
469
470 // Now collapsing the thread root, the child is already collapsed so no change.
471 api.ThreadCollapse(ctx, []int64{trashAlt.ID}, true)
472 getChanges(&chmsgthread)
473 tcompare(t, chmsgthread.ChangeThread, store.ChangeThread{MessageIDs: []int64{trashAlt.ID}, Muted: false, Collapsed: true})
474
475 // Expand thread root, including change for child.
476 api.ThreadCollapse(ctx, []int64{trashAlt.ID}, false)
477 var chmsgthread2 ChangeMsgThread
478 getChanges(&chmsgthread, &chmsgthread2)
479 tcompare(t, chmsgthread.ChangeThread, store.ChangeThread{MessageIDs: []int64{trashAlt.ID}, Muted: false, Collapsed: false})
480 tcompare(t, chmsgthread2.ChangeThread, store.ChangeThread{MessageIDs: []int64{inboxAltReply.ID}, Muted: false, Collapsed: false})
481
482 // Mute thread, including child, also collapses.
483 api.ThreadMute(ctx, []int64{trashAlt.ID}, true)
484 getChanges(&chmsgthread, &chmsgthread2)
485 tcompare(t, chmsgthread.ChangeThread, store.ChangeThread{MessageIDs: []int64{trashAlt.ID}, Muted: true, Collapsed: true})
486 tcompare(t, chmsgthread2.ChangeThread, store.ChangeThread{MessageIDs: []int64{inboxAltReply.ID}, Muted: true, Collapsed: true})
487
488 // And unmute Mute thread, including child. Messages are not expanded.
489 api.ThreadMute(ctx, []int64{trashAlt.ID}, false)
490 getChanges(&chmsgthread, &chmsgthread2)
491 tcompare(t, chmsgthread.ChangeThread, store.ChangeThread{MessageIDs: []int64{trashAlt.ID}, Muted: false, Collapsed: true})
492 tcompare(t, chmsgthread2.ChangeThread, store.ChangeThread{MessageIDs: []int64{inboxAltReply.ID}, Muted: false, Collapsed: true})
493
494 // todo: check move operations and their changes, e.g. MailboxDelete, MailboxEmpty, MessageRemove.
495}
496
497type eventReader struct {
498 t *testing.T
499 br *bufio.Reader
500 r io.Closer
501}
502
503func (r eventReader) Get(name string, event any) {
504 timer := time.AfterFunc(2*time.Second, func() {
505 r.r.Close()
506 pkglog.Print("event timeout")
507 })
508 defer timer.Stop()
509
510 t := r.t
511 t.Helper()
512 var ev string
513 var data []byte
514 var keepalive bool
515 for {
516 line, err := r.br.ReadBytes(byte('\n'))
517 tcheck(t, err, "read line")
518 line = bytes.TrimRight(line, "\n")
519 // fmt.Printf("have line %s\n", line)
520
521 if bytes.HasPrefix(line, []byte("event: ")) {
522 ev = string(line[len("event: "):])
523 } else if bytes.HasPrefix(line, []byte("data: ")) {
524 data = line[len("data: "):]
525 } else if bytes.HasPrefix(line, []byte(":")) {
526 keepalive = true
527 } else if len(line) == 0 {
528 if keepalive {
529 keepalive = false
530 continue
531 }
532 if ev != name {
533 t.Fatalf("got event %q (%s), expected %q", ev, data, name)
534 }
535 dec := json.NewDecoder(bytes.NewReader(data))
536 dec.DisallowUnknownFields()
537 err := dec.Decode(event)
538 tcheck(t, err, "unmarshal json")
539 return
540 }
541 }
542}
543