@rapidmx/web-client 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
|
@@ -1,595 +1,630 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useEffect, useState } from "react";
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import
|
|
30
|
-
import
|
|
31
|
-
import
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
*
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
/** Called with the server's updated copy
|
|
86
|
-
* mirrors `onRecalled`'s identical shape.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const [
|
|
140
|
-
const [
|
|
141
|
-
const [
|
|
142
|
-
const [
|
|
143
|
-
const [
|
|
144
|
-
const [
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const [
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
>
|
|
369
|
-
|
|
370
|
-
</
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
<
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
</
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
<
|
|
484
|
-
type="
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { HiOutlineLockClosed } from "react-icons/hi2";
|
|
7
|
+
import DOMPurify from "dompurify";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import {
|
|
10
|
+
Attachment,
|
|
11
|
+
Message,
|
|
12
|
+
MessageClassification,
|
|
13
|
+
ReceiptType,
|
|
14
|
+
approveReceipt,
|
|
15
|
+
archiveMessage,
|
|
16
|
+
attachmentContentUrl,
|
|
17
|
+
cancelScheduledSend,
|
|
18
|
+
classifyMessage,
|
|
19
|
+
declineReceipt,
|
|
20
|
+
getMessageRawContent,
|
|
21
|
+
recallMessage,
|
|
22
|
+
setMessageLabels,
|
|
23
|
+
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
24
|
+
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
25
|
+
import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
26
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
27
|
+
import { MessageSecurityResult, evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
28
|
+
import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
29
|
+
import { useCompose } from "./compose/ComposeContext.js";
|
|
30
|
+
import { useMailShell } from "./layout/MailShell.js";
|
|
31
|
+
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
32
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
33
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
34
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
35
|
+
|
|
36
|
+
/** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
|
|
37
|
+
* plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
|
|
38
|
+
* read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
|
|
39
|
+
* all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
|
|
40
|
+
* never share styling with each other or with the verified states. */
|
|
41
|
+
const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
|
|
42
|
+
unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
|
|
43
|
+
encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
|
|
44
|
+
signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
|
|
45
|
+
encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
|
|
46
|
+
signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function SecurityIndicator({ state }: { state: MessageSecurityResult["state"] }) {
|
|
50
|
+
const { label, className } = SECURITY_INDICATOR[state];
|
|
51
|
+
return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MessageDetailPaneProps {
|
|
55
|
+
message: Message | null;
|
|
56
|
+
attachments: Attachment[];
|
|
57
|
+
/** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
|
|
58
|
+
* on the desktop reading pane, which never navigates away (selecting a different message just swaps
|
|
59
|
+
* `message` in place). */
|
|
60
|
+
backHref?: string;
|
|
61
|
+
/** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
|
|
62
|
+
* Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
|
|
63
|
+
* computes this from its own already-loaded folder list rather than this component fetching folders
|
|
64
|
+
* itself. */
|
|
65
|
+
isSentItems?: boolean;
|
|
66
|
+
/** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
|
|
67
|
+
* the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
|
|
68
|
+
* `useMarkMessageRead`'s identical `onUpdated` callback. */
|
|
69
|
+
onRecalled?: (updated: Message) => void;
|
|
70
|
+
/** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
|
|
71
|
+
* from. Each caller computes this the same way it computes `isSentItems`. */
|
|
72
|
+
isOutbox?: boolean;
|
|
73
|
+
/** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
|
|
74
|
+
* scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
|
|
75
|
+
* on why clearing `scheduledSendTime` alone doesn't do that). */
|
|
76
|
+
draftsFolderUid?: string;
|
|
77
|
+
/** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
|
|
78
|
+
* successfully canceling a scheduled send. */
|
|
79
|
+
onScheduledSendCanceled?: (updated: Message) => void;
|
|
80
|
+
/** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
|
|
81
|
+
* concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
|
|
82
|
+
* the "Move to Other"/"Move to Focused" control below only renders here, the same
|
|
83
|
+
* each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
|
|
84
|
+
isInbox?: boolean;
|
|
85
|
+
/** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
|
|
86
|
+
* successful classify — mirrors `onRecalled`'s identical shape. */
|
|
87
|
+
onClassified?: (updated: Message) => void;
|
|
88
|
+
/** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
|
|
89
|
+
* mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
|
|
90
|
+
* `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
|
|
91
|
+
* only ever `true` on a real delivered copy, so the banner below is self-gating. */
|
|
92
|
+
onReceiptHandled?: (updated: Message) => void;
|
|
93
|
+
/** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
|
|
94
|
+
* successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
|
|
95
|
+
* message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
|
|
96
|
+
* server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
|
|
97
|
+
* for the Outbox-cancel flow) rather than a new prop. */
|
|
98
|
+
onArchived?: (updated: Message) => void;
|
|
99
|
+
/** Every label defined in this message's mailbox, for the label-assignment popover below — each
|
|
100
|
+
* caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
|
|
101
|
+
* (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
|
|
102
|
+
labels?: Label[];
|
|
103
|
+
/** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
|
|
104
|
+
* a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
|
|
105
|
+
* changing labels never moves a message between folders. */
|
|
106
|
+
onLabelsChanged?: (updated: Message) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatBytes(bytes: number): string {
|
|
110
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
111
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
|
|
112
|
+
return `${bytes} B`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
|
|
117
|
+
* Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
|
|
118
|
+
* the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
|
|
119
|
+
* a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
|
|
120
|
+
* site for how `message`/`attachments`/`isSentItems` are sourced.
|
|
121
|
+
*/
|
|
122
|
+
export default function MessageDetailPane({
|
|
123
|
+
message,
|
|
124
|
+
attachments,
|
|
125
|
+
backHref,
|
|
126
|
+
isSentItems,
|
|
127
|
+
onRecalled,
|
|
128
|
+
isOutbox,
|
|
129
|
+
draftsFolderUid,
|
|
130
|
+
onScheduledSendCanceled,
|
|
131
|
+
isInbox,
|
|
132
|
+
onClassified,
|
|
133
|
+
onReceiptHandled,
|
|
134
|
+
onArchived,
|
|
135
|
+
labels,
|
|
136
|
+
onLabelsChanged,
|
|
137
|
+
}: MessageDetailPaneProps) {
|
|
138
|
+
const { openCompose } = useCompose();
|
|
139
|
+
const [confirming, setConfirming] = useState(false);
|
|
140
|
+
const [recalling, setRecalling] = useState(false);
|
|
141
|
+
const [canceling, setCanceling] = useState(false);
|
|
142
|
+
const [archiving, setArchiving] = useState(false);
|
|
143
|
+
const [archiveError, setArchiveError] = useState<string | null>(null);
|
|
144
|
+
const [labelsOpen, setLabelsOpen] = useState(false);
|
|
145
|
+
const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
|
|
146
|
+
const [labelsError, setLabelsError] = useState<string | null>(null);
|
|
147
|
+
const [error, setError] = useState<string | null>(null);
|
|
148
|
+
// Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
|
|
149
|
+
// pane rather than inside a confirmation modal — the two flows never need to share one message.
|
|
150
|
+
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
151
|
+
const [classifying, setClassifying] = useState(false);
|
|
152
|
+
const [classifyError, setClassifyError] = useState<string | null>(null);
|
|
153
|
+
const [alwaysForSender, setAlwaysForSender] = useState(false);
|
|
154
|
+
// Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
|
|
155
|
+
// `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
|
|
156
|
+
// wouldn't distinguish which row's buttons should show a loading state.
|
|
157
|
+
const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
|
|
158
|
+
const [receiptError, setReceiptError] = useState<string | null>(null);
|
|
159
|
+
const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
|
|
160
|
+
const { mailboxes } = useMailShell();
|
|
161
|
+
const { requestUnlock } = useUnlockPrompt();
|
|
162
|
+
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
163
|
+
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
164
|
+
// state; see keySession.ts's own doc comment).
|
|
165
|
+
const [unlockRefresh, setUnlockRefresh] = useState(0);
|
|
166
|
+
|
|
167
|
+
// Evaluates every message's security state, not just ones flagged `encrypted` - a detached
|
|
168
|
+
// `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
|
|
169
|
+
// signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
|
|
170
|
+
// degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
|
|
171
|
+
// safe default, not a distinct failure state of its own.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!message) {
|
|
174
|
+
setSecurity(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
setSecurity(null);
|
|
179
|
+
getMessageRawContent(message.uid)
|
|
180
|
+
.then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
|
|
181
|
+
.then((result) => {
|
|
182
|
+
if (!cancelled) {
|
|
183
|
+
setSecurity(result);
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
.catch(() => {
|
|
187
|
+
if (!cancelled) {
|
|
188
|
+
setSecurity({ state: "unprotected" });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
cancelled = true;
|
|
193
|
+
};
|
|
194
|
+
}, [message?.uid, unlockRefresh]);
|
|
195
|
+
|
|
196
|
+
// Offered only when this device genuinely has no unlocked session for this message's mailbox at all
|
|
197
|
+
// (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
|
|
198
|
+
// re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
|
|
199
|
+
// `decryptError` alone can't distinguish those two cases.
|
|
200
|
+
async function handleUnlockToView() {
|
|
201
|
+
// Only reachable via the button below, which never renders while `message` is null.
|
|
202
|
+
const mailboxUid = message!.mailboxUid;
|
|
203
|
+
const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
|
|
204
|
+
try {
|
|
205
|
+
await requestUnlock(mailboxUid, mailboxKeys);
|
|
206
|
+
setUnlockRefresh((n) => n + 1);
|
|
207
|
+
} catch {
|
|
208
|
+
// User dismissed the unlock dialog - security state stays exactly as it was.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!message) {
|
|
213
|
+
return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
|
|
217
|
+
// once `message` is loaded (the early return above covers the only other state) — the non-null
|
|
218
|
+
// assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
|
|
219
|
+
function handleReply() {
|
|
220
|
+
openCompose({
|
|
221
|
+
mailboxUid: message!.mailboxUid,
|
|
222
|
+
to: message!.from.address,
|
|
223
|
+
subject: replySubject(message!.subject),
|
|
224
|
+
quotedHtml: buildReplyQuote(message!),
|
|
225
|
+
signatureContext: "reply_forward",
|
|
226
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleReplyAll() {
|
|
231
|
+
const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
|
|
232
|
+
openCompose({
|
|
233
|
+
mailboxUid: message!.mailboxUid,
|
|
234
|
+
to: message!.from.address,
|
|
235
|
+
cc: cc.join(", "),
|
|
236
|
+
subject: replySubject(message!.subject),
|
|
237
|
+
quotedHtml: buildReplyQuote(message!),
|
|
238
|
+
signatureContext: "reply_forward",
|
|
239
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handleForward() {
|
|
244
|
+
openCompose({
|
|
245
|
+
mailboxUid: message!.mailboxUid,
|
|
246
|
+
subject: forwardSubject(message!.subject),
|
|
247
|
+
quotedHtml: buildForwardQuote(message!),
|
|
248
|
+
signatureContext: "reply_forward",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function handleRecall() {
|
|
253
|
+
// Only ever invoked from the confirmation modal below, which itself only renders once `message`
|
|
254
|
+
// is loaded (the early return above covers the only other state) — the non-null assertion
|
|
255
|
+
// reflects that real invariant, matching this codebase's established pattern for the same class
|
|
256
|
+
// of always-true-in-practice guard.
|
|
257
|
+
setRecalling(true);
|
|
258
|
+
setError(null);
|
|
259
|
+
try {
|
|
260
|
+
const updated = await recallMessage(message!.uid);
|
|
261
|
+
setConfirming(false);
|
|
262
|
+
onRecalled?.(updated);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
|
|
265
|
+
} finally {
|
|
266
|
+
setRecalling(false);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
|
|
271
|
+
// loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
|
|
272
|
+
// by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
|
|
273
|
+
// a real invariant, matching `handleRecall`'s identical pattern just above.
|
|
274
|
+
async function handleCancelScheduledSend() {
|
|
275
|
+
setCanceling(true);
|
|
276
|
+
setCancelError(null);
|
|
277
|
+
try {
|
|
278
|
+
const updated = await cancelScheduledSend(message!, draftsFolderUid!);
|
|
279
|
+
onScheduledSendCanceled?.(updated);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
|
|
282
|
+
} finally {
|
|
283
|
+
setCanceling(false);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Only ever invoked from the Archive button below, which itself only renders once `message` is
|
|
288
|
+
// loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
|
|
289
|
+
// `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
|
|
290
|
+
async function handleArchive() {
|
|
291
|
+
setArchiving(true);
|
|
292
|
+
setArchiveError(null);
|
|
293
|
+
try {
|
|
294
|
+
const updated = await archiveMessage(message!.uid);
|
|
295
|
+
onArchived?.(updated);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
|
|
298
|
+
} finally {
|
|
299
|
+
setArchiving(false);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
|
|
304
|
+
// `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
|
|
305
|
+
// new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
|
|
306
|
+
// list rather than patching a single entry.
|
|
307
|
+
async function handleToggleLabel(labelUid: string) {
|
|
308
|
+
setTogglingLabelUid(labelUid);
|
|
309
|
+
setLabelsError(null);
|
|
310
|
+
try {
|
|
311
|
+
const current = message!.labelUids ?? [];
|
|
312
|
+
const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
|
|
313
|
+
const updated = await setMessageLabels(message!, next);
|
|
314
|
+
onLabelsChanged?.(updated);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
|
|
317
|
+
} finally {
|
|
318
|
+
setTogglingLabelUid(null);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Only ever invoked from the classification button below, which itself only renders once `message`
|
|
323
|
+
// is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
|
|
324
|
+
// `handleRecall`/`handleCancelScheduledSend` above.
|
|
325
|
+
async function handleClassify(classifyAs: MessageClassification) {
|
|
326
|
+
setClassifying(true);
|
|
327
|
+
setClassifyError(null);
|
|
328
|
+
try {
|
|
329
|
+
const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
|
|
330
|
+
onClassified?.(updated);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
|
|
333
|
+
} finally {
|
|
334
|
+
setClassifying(false);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
339
|
+
// is loaded — same real invariant as every other handler above.
|
|
340
|
+
async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
|
|
341
|
+
setReceiptBusy(type);
|
|
342
|
+
setReceiptError(null);
|
|
343
|
+
try {
|
|
344
|
+
const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
|
|
345
|
+
onReceiptHandled?.(updated);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
|
|
348
|
+
} finally {
|
|
349
|
+
setReceiptBusy(null);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
355
|
+
<div className="border-b border-border p-4">
|
|
356
|
+
{backHref && (
|
|
357
|
+
<a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
|
|
358
|
+
← Back to messages
|
|
359
|
+
</a>
|
|
360
|
+
)}
|
|
361
|
+
<div className="flex items-start justify-between gap-3">
|
|
362
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
363
|
+
<h1 className="text-lg font-bold tracking-tight truncate">{message.subject || "(no subject)"}</h1>
|
|
364
|
+
{security && <SecurityIndicator state={security.state} />}
|
|
365
|
+
</div>
|
|
366
|
+
{isSentItems &&
|
|
367
|
+
(message.recallRequestedAt ? (
|
|
368
|
+
<span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
369
|
+
Recall requested
|
|
370
|
+
</span>
|
|
371
|
+
) : (
|
|
372
|
+
<Button
|
|
373
|
+
type="button"
|
|
374
|
+
variant="secondary"
|
|
375
|
+
className="!w-auto shrink-0"
|
|
376
|
+
onClick={() => setConfirming(true)}
|
|
377
|
+
>
|
|
378
|
+
Recall this message
|
|
379
|
+
</Button>
|
|
380
|
+
))}
|
|
381
|
+
{isOutbox && message.scheduledSendTime && (
|
|
382
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
383
|
+
<span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
384
|
+
Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
|
|
385
|
+
</span>
|
|
386
|
+
<Button
|
|
387
|
+
type="button"
|
|
388
|
+
variant="secondary"
|
|
389
|
+
className="!w-auto"
|
|
390
|
+
loading={canceling}
|
|
391
|
+
disabled={canceling}
|
|
392
|
+
onClick={handleCancelScheduledSend}
|
|
393
|
+
>
|
|
394
|
+
Cancel
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
{cancelError && (
|
|
400
|
+
<div className="mt-2">
|
|
401
|
+
<Alert>{cancelError}</Alert>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
{security?.headerTamperDetected && (
|
|
405
|
+
<div className="mt-2">
|
|
406
|
+
<Alert>
|
|
407
|
+
This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
|
|
408
|
+
encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
|
|
409
|
+
caution.
|
|
410
|
+
</Alert>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
<p className="text-sm text-text-muted mt-1">
|
|
414
|
+
From {message.from.displayName || message.from.address} ·{" "}
|
|
415
|
+
{new Date(message.receivedDate).toLocaleString()}
|
|
416
|
+
</p>
|
|
417
|
+
<p className="text-sm text-text-muted">
|
|
418
|
+
To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
|
|
419
|
+
</p>
|
|
420
|
+
<div className="flex gap-2 mt-3">
|
|
421
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
|
|
422
|
+
Reply
|
|
423
|
+
</Button>
|
|
424
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
|
|
425
|
+
Reply All
|
|
426
|
+
</Button>
|
|
427
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
|
|
428
|
+
Forward
|
|
429
|
+
</Button>
|
|
430
|
+
{!isOutbox && message.folderUid !== draftsFolderUid && (
|
|
431
|
+
<Button
|
|
432
|
+
type="button"
|
|
433
|
+
variant="secondary"
|
|
434
|
+
className="!w-auto"
|
|
435
|
+
loading={archiving}
|
|
436
|
+
disabled={archiving}
|
|
437
|
+
onClick={handleArchive}
|
|
438
|
+
>
|
|
439
|
+
Archive
|
|
440
|
+
</Button>
|
|
441
|
+
)}
|
|
442
|
+
{labels && labels.length > 0 && (
|
|
443
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
|
|
444
|
+
Labels
|
|
445
|
+
</Button>
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
{archiveError && (
|
|
449
|
+
<div className="mt-2">
|
|
450
|
+
<Alert>{archiveError}</Alert>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
{labels && (message.labelUids?.length ?? 0) > 0 && (
|
|
454
|
+
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
|
455
|
+
{message
|
|
456
|
+
.labelUids!.map((uid) => labels.find((l) => l.uid === uid))
|
|
457
|
+
.filter((l): l is Label => !!l)
|
|
458
|
+
.map((l) => (
|
|
459
|
+
<span
|
|
460
|
+
key={l.uid}
|
|
461
|
+
className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
|
|
462
|
+
>
|
|
463
|
+
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
|
|
464
|
+
{l.name}
|
|
465
|
+
</span>
|
|
466
|
+
))}
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
{isInbox && (
|
|
470
|
+
<div className="flex flex-wrap items-center gap-2 mt-2">
|
|
471
|
+
<Button
|
|
472
|
+
type="button"
|
|
473
|
+
variant="text"
|
|
474
|
+
loading={classifying}
|
|
475
|
+
disabled={classifying}
|
|
476
|
+
onClick={() =>
|
|
477
|
+
handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
|
|
478
|
+
}
|
|
479
|
+
>
|
|
480
|
+
{message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
|
|
481
|
+
</Button>
|
|
482
|
+
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
483
|
+
<input
|
|
484
|
+
type="checkbox"
|
|
485
|
+
checked={alwaysForSender}
|
|
486
|
+
onChange={(e) => setAlwaysForSender(e.target.checked)}
|
|
487
|
+
/>
|
|
488
|
+
Always for this sender
|
|
489
|
+
</label>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
{classifyError && (
|
|
493
|
+
<div className="mt-2">
|
|
494
|
+
<Alert>{classifyError}</Alert>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
{(["delivery", "read"] as const)
|
|
498
|
+
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
499
|
+
.map((type) => (
|
|
500
|
+
<div
|
|
501
|
+
key={type}
|
|
502
|
+
className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
|
|
503
|
+
>
|
|
504
|
+
<span>
|
|
505
|
+
{message.from.displayName || message.from.address} requested a {type} receipt for this
|
|
506
|
+
message.
|
|
507
|
+
</span>
|
|
508
|
+
<Button
|
|
509
|
+
type="button"
|
|
510
|
+
variant="secondary"
|
|
511
|
+
className="!w-auto"
|
|
512
|
+
loading={receiptBusy === type}
|
|
513
|
+
disabled={receiptBusy !== null}
|
|
514
|
+
onClick={() => handleReceipt(type, "approve")}
|
|
515
|
+
>
|
|
516
|
+
Send receipt
|
|
517
|
+
</Button>
|
|
518
|
+
<Button
|
|
519
|
+
type="button"
|
|
520
|
+
variant="text"
|
|
521
|
+
disabled={receiptBusy !== null}
|
|
522
|
+
onClick={() => handleReceipt(type, "decline")}
|
|
523
|
+
>
|
|
524
|
+
Decline
|
|
525
|
+
</Button>
|
|
526
|
+
</div>
|
|
527
|
+
))}
|
|
528
|
+
{receiptError && (
|
|
529
|
+
<div className="mt-2">
|
|
530
|
+
<Alert>{receiptError}</Alert>
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
{attachments.length > 0 && (
|
|
534
|
+
<ul className="flex flex-wrap gap-2 mt-3">
|
|
535
|
+
{attachments.map((attachment) => (
|
|
536
|
+
<li key={attachment.uid}>
|
|
537
|
+
<a
|
|
538
|
+
href={attachmentContentUrl(attachment.uid)}
|
|
539
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
540
|
+
>
|
|
541
|
+
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
542
|
+
</a>
|
|
543
|
+
</li>
|
|
544
|
+
))}
|
|
545
|
+
</ul>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
{security?.decryptError && (
|
|
549
|
+
<div className="px-4 pt-2">
|
|
550
|
+
<Alert>{security.decryptError}</Alert>
|
|
551
|
+
{message && !getUnlockedKeys(message.mailboxUid) && (
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
onClick={handleUnlockToView}
|
|
555
|
+
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
556
|
+
>
|
|
557
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
558
|
+
Unlock to view this message
|
|
559
|
+
</button>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
{security?.html !== undefined ? (
|
|
564
|
+
// A decrypted/verified body never came through the server's own sanitize-html pass (it
|
|
565
|
+
// couldn't - the server never saw the plaintext) - DOMPurify sanitizes it here, client-side,
|
|
566
|
+
// before it ever touches the DOM, on top of (not instead of) the iframe's own `sandbox=""`.
|
|
567
|
+
<iframe
|
|
568
|
+
key={message.uid}
|
|
569
|
+
title={message.subject || "Message content"}
|
|
570
|
+
srcDoc={DOMPurify.sanitize(security.html)}
|
|
571
|
+
sandbox=""
|
|
572
|
+
className="flex-1 w-full border-0"
|
|
573
|
+
/>
|
|
574
|
+
) : (
|
|
575
|
+
<iframe
|
|
576
|
+
key={message.uid}
|
|
577
|
+
title={message.subject || "Message content"}
|
|
578
|
+
src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
|
|
579
|
+
sandbox=""
|
|
580
|
+
className="flex-1 w-full border-0"
|
|
581
|
+
/>
|
|
582
|
+
)}
|
|
583
|
+
|
|
584
|
+
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
585
|
+
<p className="text-sm text-text-muted mb-4">
|
|
586
|
+
This asks every original recipient's mail system to delete their copy, but only if it's
|
|
587
|
+
still unread there — there's no way to guarantee it, and no confirmation once it either
|
|
588
|
+
succeeds or fails. Recipients who already read the message will keep it.
|
|
589
|
+
</p>
|
|
590
|
+
{error && <Alert>{error}</Alert>}
|
|
591
|
+
<div className="flex gap-3">
|
|
592
|
+
<Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
|
|
593
|
+
Recall message
|
|
594
|
+
</Button>
|
|
595
|
+
<Button
|
|
596
|
+
type="button"
|
|
597
|
+
variant="secondary"
|
|
598
|
+
disabled={recalling}
|
|
599
|
+
onClick={() => setConfirming(false)}
|
|
600
|
+
className="!w-auto"
|
|
601
|
+
>
|
|
602
|
+
Cancel
|
|
603
|
+
</Button>
|
|
604
|
+
</div>
|
|
605
|
+
</Modal>
|
|
606
|
+
<Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
|
|
607
|
+
{labelsError && <Alert>{labelsError}</Alert>}
|
|
608
|
+
<ul className="flex flex-col gap-1">
|
|
609
|
+
{(labels ?? []).map((l) => {
|
|
610
|
+
const checked = message.labelUids?.includes(l.uid) ?? false;
|
|
611
|
+
return (
|
|
612
|
+
<li key={l.uid}>
|
|
613
|
+
<label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
|
|
614
|
+
<input
|
|
615
|
+
type="checkbox"
|
|
616
|
+
checked={checked}
|
|
617
|
+
disabled={togglingLabelUid === l.uid}
|
|
618
|
+
onChange={() => handleToggleLabel(l.uid)}
|
|
619
|
+
/>
|
|
620
|
+
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
|
|
621
|
+
{l.name}
|
|
622
|
+
</label>
|
|
623
|
+
</li>
|
|
624
|
+
);
|
|
625
|
+
})}
|
|
626
|
+
</ul>
|
|
627
|
+
</Modal>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|