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