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