@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,765 +1,930 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
6
|
-
import {
|
|
7
|
-
HiOutlineArrowsPointingIn,
|
|
8
|
-
HiOutlineArrowsPointingOut,
|
|
9
|
-
HiOutlineChevronDown,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import
|
|
39
|
-
import
|
|
40
|
-
import
|
|
41
|
-
import
|
|
42
|
-
import
|
|
43
|
-
import
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const [
|
|
120
|
-
const
|
|
121
|
-
const [
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const [
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const [
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const [
|
|
133
|
-
const [
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const [
|
|
139
|
-
const [
|
|
140
|
-
const [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const [
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const [
|
|
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
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
/>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
<
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
{
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineArrowsPointingIn,
|
|
8
|
+
HiOutlineArrowsPointingOut,
|
|
9
|
+
HiOutlineChevronDown,
|
|
10
|
+
HiOutlineLockClosed,
|
|
11
|
+
HiOutlineMinus,
|
|
12
|
+
HiOutlinePaperClip,
|
|
13
|
+
HiOutlineTrash,
|
|
14
|
+
HiOutlineXMark,
|
|
15
|
+
} from "react-icons/hi2";
|
|
16
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
17
|
+
import {
|
|
18
|
+
Attachment,
|
|
19
|
+
ComposeRecipientInput,
|
|
20
|
+
Mailbox,
|
|
21
|
+
Message,
|
|
22
|
+
assembleDraft,
|
|
23
|
+
assembleDraftRaw,
|
|
24
|
+
attachmentContentUrl,
|
|
25
|
+
createDraft,
|
|
26
|
+
deleteMessage,
|
|
27
|
+
getMailbox,
|
|
28
|
+
listFolders,
|
|
29
|
+
listMailboxes,
|
|
30
|
+
sendMessage,
|
|
31
|
+
setMessageRequestReceipt,
|
|
32
|
+
setMessageScheduledSendTime,
|
|
33
|
+
uploadAttachment,
|
|
34
|
+
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
35
|
+
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
36
|
+
import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
37
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
38
|
+
import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
39
|
+
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
40
|
+
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
41
|
+
import { ProtectedHeaders, applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
42
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
43
|
+
import type { ComposeSession } from "./ComposeContext.js";
|
|
44
|
+
import RichTextEditor from "./RichTextEditor.js";
|
|
45
|
+
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
46
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
47
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
48
|
+
|
|
49
|
+
export interface ComposeWindowProps {
|
|
50
|
+
session: ComposeSession;
|
|
51
|
+
onClose: () => void;
|
|
52
|
+
onToggleMinimize: () => void;
|
|
53
|
+
/** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
|
|
54
|
+
* doesn't name a mailbox. */
|
|
55
|
+
userUid?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
59
|
+
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
60
|
+
|
|
61
|
+
const MIN_WIDTH = 320;
|
|
62
|
+
const MIN_HEIGHT = 320;
|
|
63
|
+
// Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
|
|
64
|
+
// handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
|
|
65
|
+
// Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
|
|
66
|
+
// module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
|
|
67
|
+
// during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
|
|
68
|
+
// not just this component's.
|
|
69
|
+
const VIEWPORT_MARGIN = 24;
|
|
70
|
+
|
|
71
|
+
interface Size {
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ResizeEdge = "left" | "top" | "corner";
|
|
77
|
+
|
|
78
|
+
function parseAddresses(value: string): ComposeRecipientInput[] {
|
|
79
|
+
return value
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter((s) => s.length > 0)
|
|
83
|
+
.map((address) => ({ address }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick: () => void; icon: React.ComponentType<{ size?: number }> }) {
|
|
87
|
+
return (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label={label}
|
|
91
|
+
title={label}
|
|
92
|
+
onClick={onClick}
|
|
93
|
+
className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
|
|
94
|
+
>
|
|
95
|
+
<Icon size={14} />
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
|
|
102
|
+
* navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
|
|
103
|
+
* form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
|
|
104
|
+
* `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
|
|
105
|
+
* own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
|
|
106
|
+
* context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
|
|
107
|
+
* "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
|
|
108
|
+
*
|
|
109
|
+
* Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
|
|
110
|
+
* room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
|
|
111
|
+
* see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
|
|
112
|
+
* once.
|
|
113
|
+
*/
|
|
114
|
+
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid }: ComposeWindowProps) {
|
|
115
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
|
|
116
|
+
// The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
|
|
117
|
+
// compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
|
|
118
|
+
// Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
|
|
119
|
+
const [fromMailboxUid, setFromMailboxUid] = useState<string | undefined>(session.mailboxUid);
|
|
120
|
+
const mailboxUid = fromMailboxUid;
|
|
121
|
+
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
|
122
|
+
// Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
|
|
123
|
+
// Drafts folder - so once anything has been uploaded the sender can no longer be switched.
|
|
124
|
+
const [hasUploads, setHasUploads] = useState(false);
|
|
125
|
+
const isMobile = useIsMobile();
|
|
126
|
+
const { requestUnlock } = useUnlockPrompt();
|
|
127
|
+
// Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
|
|
128
|
+
// plain read from a module-level store, not React state, so nothing else would pick up the change.
|
|
129
|
+
const [, setUnlockRefresh] = useState(0);
|
|
130
|
+
|
|
131
|
+
const windowRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
|
|
133
|
+
const [folderError, setFolderError] = useState<string | null>(null);
|
|
134
|
+
const [draft, setDraft] = useState<Message | null>(null);
|
|
135
|
+
const [draftError, setDraftError] = useState<string | null>(null);
|
|
136
|
+
const [expanded, setExpanded] = useState(false);
|
|
137
|
+
const [manualSize, setManualSize] = useState<Size | null>(null);
|
|
138
|
+
const [to, setTo] = useState(initialTo ?? "");
|
|
139
|
+
const [cc, setCc] = useState(initialCc ?? "");
|
|
140
|
+
const [bcc, setBcc] = useState("");
|
|
141
|
+
const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
|
|
142
|
+
const [subject, setSubject] = useState(initialSubject ?? "");
|
|
143
|
+
const [html, setHtml] = useState("");
|
|
144
|
+
const [contentReady, setContentReady] = useState(false);
|
|
145
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
146
|
+
const [attachError, setAttachError] = useState<string | null>(null);
|
|
147
|
+
const [sendError, setSendError] = useState<string | null>(null);
|
|
148
|
+
const [sending, setSending] = useState(false);
|
|
149
|
+
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
150
|
+
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
151
|
+
const scheduleButtonRef = useRef<HTMLButtonElement>(null);
|
|
152
|
+
const [mailbox, setMailbox] = useState<Mailbox | null>(null);
|
|
153
|
+
const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
|
|
154
|
+
// Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
|
|
155
|
+
// Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
|
|
156
|
+
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
157
|
+
// still turn it back on; this only changes the default.
|
|
158
|
+
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
159
|
+
const [encryptRequested, setEncryptRequested] = useState(false);
|
|
160
|
+
const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
|
|
161
|
+
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
162
|
+
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
163
|
+
// just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
|
|
164
|
+
// never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
|
|
165
|
+
const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
|
|
166
|
+
|
|
167
|
+
// Best-effort, same as the signature-list fetch below: a mailbox with no keys enrolled yet (or a
|
|
168
|
+
// failed fetch) just means sign/encrypt stay unavailable for this compose session, never a blocking
|
|
169
|
+
// error - encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
|
|
170
|
+
// `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
|
|
171
|
+
// send that happens to race this fetch can't silently skip encryption the spec says should apply.
|
|
172
|
+
const [cryptoContextReady, setCryptoContextReady] = useState(false);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
listMailboxes({ limit: 100 })
|
|
177
|
+
.then((result) => {
|
|
178
|
+
if (cancelled) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
setMailboxes(result);
|
|
182
|
+
setFromMailboxUid((current) => current ?? (result.find((mb) => mb.ownerUserUid === userUid) ?? result[0])?.uid);
|
|
183
|
+
})
|
|
184
|
+
.catch((err) => {
|
|
185
|
+
// Only fatal when there's no mailbox to fall back on - a session that already names one just
|
|
186
|
+
// loses the From picker.
|
|
187
|
+
if (!cancelled && !session.mailboxUid) {
|
|
188
|
+
setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
cancelled = true;
|
|
193
|
+
};
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!mailboxUid) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let cancelled = false;
|
|
202
|
+
setCryptoContextReady(false);
|
|
203
|
+
let mailboxSettled = false;
|
|
204
|
+
let policySettled = false;
|
|
205
|
+
function checkReady() {
|
|
206
|
+
if (!cancelled && mailboxSettled && policySettled) {
|
|
207
|
+
setCryptoContextReady(true);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
getMailbox(mailboxUid)
|
|
211
|
+
.then((result) => {
|
|
212
|
+
if (!cancelled) {
|
|
213
|
+
setMailbox(result);
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
.catch(() => undefined)
|
|
217
|
+
.finally(() => {
|
|
218
|
+
mailboxSettled = true;
|
|
219
|
+
checkReady();
|
|
220
|
+
});
|
|
221
|
+
getEncryptionPolicy()
|
|
222
|
+
.then((result) => {
|
|
223
|
+
if (!cancelled) {
|
|
224
|
+
setEncryptionPolicy(result);
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
.catch(() => undefined)
|
|
228
|
+
.finally(() => {
|
|
229
|
+
policySettled = true;
|
|
230
|
+
checkReady();
|
|
231
|
+
});
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true;
|
|
234
|
+
};
|
|
235
|
+
}, [mailboxUid]);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!mailboxUid) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Cancellable so a slow response for a mailbox the user has since switched away from can't point
|
|
242
|
+
// the new draft at the wrong mailbox's Drafts folder.
|
|
243
|
+
let cancelled = false;
|
|
244
|
+
listFolders(mailboxUid)
|
|
245
|
+
.then((folders) => {
|
|
246
|
+
if (!cancelled) {
|
|
247
|
+
setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.catch((err) => {
|
|
251
|
+
if (!cancelled) {
|
|
252
|
+
setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return () => {
|
|
256
|
+
cancelled = true;
|
|
257
|
+
};
|
|
258
|
+
}, [mailboxUid]);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
|
|
262
|
+
* caching the result so the small per-recipient indicator below the recipient fields can update
|
|
263
|
+
* without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
|
|
264
|
+
* and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
|
|
265
|
+
* best-effort UI hint, not something a stale value here should be trusted to skip at send time).
|
|
266
|
+
* A failed lookup or a mailbox/policy fetch that hasn't resolved yet just leaves that address with no
|
|
267
|
+
* indicator rather than surfacing an error over what's a cosmetic nicety.
|
|
268
|
+
*/
|
|
269
|
+
function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
|
|
270
|
+
if (!mailbox || !encryptionPolicy) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
274
|
+
const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
|
|
275
|
+
for (const recipient of unchecked) {
|
|
276
|
+
lookupKeys(mailbox.uid, recipient.address)
|
|
277
|
+
.catch(() => undefined)
|
|
278
|
+
.then((lookup) => {
|
|
279
|
+
const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
|
|
280
|
+
setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
286
|
+
// plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
287
|
+
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
288
|
+
// content and never re-syncs from a later prop change, so this has to resolve before that first
|
|
289
|
+
// mount, not after. A signature-list failure is best-effort, same as every other supplementary,
|
|
290
|
+
// non-blocking fetch in this codebase (e.g. `ConversationThreadPane`'s attachment fetch) — no
|
|
291
|
+
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
292
|
+
// (if any) rather than surfacing an error over what's a cosmetic nicety.
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
// Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
|
|
295
|
+
// later must not overwrite what the user has already written (the editor itself never re-syncs).
|
|
296
|
+
if (!mailboxUid || contentReady) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
listMailSignatures(mailboxUid)
|
|
300
|
+
.then((signatures) => {
|
|
301
|
+
const signature = signatures.find((s) =>
|
|
302
|
+
signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
|
|
303
|
+
);
|
|
304
|
+
const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
|
|
305
|
+
setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
|
|
306
|
+
})
|
|
307
|
+
.catch(() => setHtml(initialQuotedHtml ?? ""))
|
|
308
|
+
.finally(() => setContentReady(true));
|
|
309
|
+
}, [mailboxUid, signatureContext, initialQuotedHtml]);
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!mailboxUid || !draftsFolderUid || draft) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
let cancelled = false;
|
|
316
|
+
createDraft(mailboxUid, draftsFolderUid)
|
|
317
|
+
.then((created) => {
|
|
318
|
+
if (cancelled) {
|
|
319
|
+
// The sender changed while this was in flight - discard the now-orphaned draft.
|
|
320
|
+
void deleteMessage(created.uid, created.version).catch(() => undefined);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
setDraft(created);
|
|
324
|
+
})
|
|
325
|
+
.catch((err) => {
|
|
326
|
+
if (!cancelled) {
|
|
327
|
+
setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return () => {
|
|
331
|
+
cancelled = true;
|
|
332
|
+
};
|
|
333
|
+
}, [mailboxUid, draftsFolderUid, draft]);
|
|
334
|
+
|
|
335
|
+
/** Switches the sending mailbox: discards the current (unsent, upload-free) draft and resets the
|
|
336
|
+
* per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox.
|
|
337
|
+
* Recipients, subject, and body are kept. */
|
|
338
|
+
function handleFromChange(nextMailboxUid: string) {
|
|
339
|
+
if (nextMailboxUid === mailboxUid || hasUploads) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (draft) {
|
|
343
|
+
void deleteMessage(draft.uid, draft.version).catch(() => undefined);
|
|
344
|
+
}
|
|
345
|
+
setDraft(null);
|
|
346
|
+
setDraftsFolderUid(undefined);
|
|
347
|
+
setDraftError(null);
|
|
348
|
+
setFolderError(null);
|
|
349
|
+
setMailbox(null);
|
|
350
|
+
setRecipientStatuses({});
|
|
351
|
+
setEncryptRequested(false);
|
|
352
|
+
setEncryptionBlocked(null);
|
|
353
|
+
setFromMailboxUid(nextMailboxUid);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
|
|
358
|
+
* at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
|
|
359
|
+
* always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
|
|
360
|
+
* created per drag gesture (captured over this gesture's own start position/size) rather than
|
|
361
|
+
* stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
|
|
362
|
+
* the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
|
|
363
|
+
* reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
|
|
364
|
+
* listener instead of the current one.
|
|
365
|
+
*/
|
|
366
|
+
function handleResizeStart(edge: ResizeEdge) {
|
|
367
|
+
return (e: React.PointerEvent) => {
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
const rect = windowRef.current!.getBoundingClientRect();
|
|
370
|
+
const startX = e.clientX;
|
|
371
|
+
const startY = e.clientY;
|
|
372
|
+
const startWidth = rect.width;
|
|
373
|
+
const startHeight = rect.height;
|
|
374
|
+
|
|
375
|
+
function onMove(ev: PointerEvent) {
|
|
376
|
+
const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
|
|
377
|
+
const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
|
|
378
|
+
const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
|
|
379
|
+
const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
|
|
380
|
+
setManualSize({ width, height });
|
|
381
|
+
}
|
|
382
|
+
function onUp() {
|
|
383
|
+
window.removeEventListener("pointermove", onMove);
|
|
384
|
+
window.removeEventListener("pointerup", onUp);
|
|
385
|
+
}
|
|
386
|
+
window.addEventListener("pointermove", onMove);
|
|
387
|
+
window.addEventListener("pointerup", onUp);
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function toggleExpanded() {
|
|
392
|
+
setExpanded((v) => !v);
|
|
393
|
+
setManualSize(null);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
|
|
397
|
+
* it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
|
|
398
|
+
* that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
|
|
399
|
+
* `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
|
|
400
|
+
* than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
|
|
401
|
+
* toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
|
|
402
|
+
* things load in parallel, so a real (if narrow) window exists where the button is clickable before
|
|
403
|
+
* `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
|
|
404
|
+
*/
|
|
405
|
+
async function handleUploadImage(file: File): Promise<string | null> {
|
|
406
|
+
if (!draft) {
|
|
407
|
+
setAttachError("Please wait for the draft to finish loading before inserting an image.");
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const attachment = await uploadAttachment(draft.uid, file);
|
|
412
|
+
setHasUploads(true);
|
|
413
|
+
return attachmentContentUrl(attachment.uid);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
|
|
421
|
+
// below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
|
|
422
|
+
// this codebase's established pattern for the same class of "always non-null by the time it's
|
|
423
|
+
// called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
|
|
424
|
+
// below is already a no-op when there's nothing to iterate.
|
|
425
|
+
async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
|
|
426
|
+
const files = Array.from(e.target.files ?? []);
|
|
427
|
+
e.target.value = "";
|
|
428
|
+
setAttachError(null);
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
try {
|
|
431
|
+
const attachment = await uploadAttachment(draft!.uid, file);
|
|
432
|
+
setHasUploads(true);
|
|
433
|
+
setAttachments((prev) => [...prev, attachment]);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
|
|
442
|
+
* `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
|
|
443
|
+
* Shared by `handleSend()`/`handleScheduleSend()` (and the "Send without encryption" conflict button
|
|
444
|
+
* rendered below, via `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
|
|
445
|
+
*
|
|
446
|
+
* Returns `"blocked"` (never throws for this case) when encryption was requested but not every
|
|
447
|
+
* recipient can currently be encrypted to — the spec's "Multiple Recipients" all-or-nothing rule.
|
|
448
|
+
* Sets `encryptionBlocked` as a side effect so the window can render that prompt; callers must stop
|
|
449
|
+
* (not send) when they get this back.
|
|
450
|
+
*
|
|
451
|
+
* Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
|
|
452
|
+
* pre-send step — not live as recipients are typed. A compose-time recipient-entry indicator (key
|
|
453
|
+
* icons, live discovery as each address is entered) is Phase 4's "Discovery & contacts UI" work, not
|
|
454
|
+
* this pass; this still satisfies the spec's "MUST be called lazily at compose time, not on receipt"
|
|
455
|
+
* requirement, just without a live UI reflecting it before Send is clicked.
|
|
456
|
+
*/
|
|
457
|
+
async function assembleForSend(
|
|
458
|
+
toRecipients: ComposeRecipientInput[],
|
|
459
|
+
ccRecipients: ComposeRecipientInput[],
|
|
460
|
+
bccRecipients: ComposeRecipientInput[],
|
|
461
|
+
forcePlaintext: boolean,
|
|
462
|
+
): Promise<Message | "blocked"> {
|
|
463
|
+
// Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
|
|
464
|
+
const unlocked = getUnlockedKeys(mailboxUid!);
|
|
465
|
+
// `mailbox` is required to build protected headers (own From address) whenever signing or
|
|
466
|
+
// encrypting - not just guarded on the encryption branch below, since a signed-only message
|
|
467
|
+
// needs it too. A mailbox fetch failure just means no crypto for this send, never a crash.
|
|
468
|
+
const canSign = signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer;
|
|
469
|
+
const canEncryptSelf = !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer;
|
|
470
|
+
const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
|
|
471
|
+
|
|
472
|
+
let wantEncrypt = false;
|
|
473
|
+
let recipientCertDers: Uint8Array[] = [];
|
|
474
|
+
if (!forcePlaintext && canEncryptSelf && mailbox && encryptionPolicy && allRecipients.length > 0) {
|
|
475
|
+
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
476
|
+
const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid!, r.address).catch(() => undefined)));
|
|
477
|
+
const statuses = allRecipients.map((r, i) =>
|
|
478
|
+
resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]),
|
|
479
|
+
);
|
|
480
|
+
const decision = decideMessageEncryption(statuses);
|
|
481
|
+
wantEncrypt = encryptRequested || decision.autoEncrypt;
|
|
482
|
+
if (wantEncrypt) {
|
|
483
|
+
if (!decision.canEncryptAll) {
|
|
484
|
+
setEncryptionBlocked(decision.blockedRecipients);
|
|
485
|
+
return "blocked";
|
|
486
|
+
}
|
|
487
|
+
recipientCertDers = [unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if ((wantEncrypt || canSign) && attachments.length > 0) {
|
|
492
|
+
// Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
|
|
493
|
+
// clearer explanation than that route's generic 400 rather than via a round trip.
|
|
494
|
+
throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (!wantEncrypt && !canSign) {
|
|
498
|
+
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
499
|
+
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
500
|
+
// client-side pass is done here either.
|
|
501
|
+
return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
|
|
505
|
+
const protectedHeaders: ProtectedHeaders = {
|
|
506
|
+
from: mailbox!.displayName
|
|
507
|
+
? `"${mailbox!.displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
|
|
508
|
+
: mailbox!.primarySmtpAddress,
|
|
509
|
+
to: toRecipients.map((r) => r.address).join(", "),
|
|
510
|
+
cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
|
|
511
|
+
date: new Date().toUTCString(),
|
|
512
|
+
subject,
|
|
513
|
+
messageId: `<${crypto.randomUUID()}@${domain}>`,
|
|
514
|
+
};
|
|
515
|
+
const bodyContentType = 'text/html; charset="utf-8"';
|
|
516
|
+
const signing = canSign ? { certDer: unlocked!.signingCertDer!, privateKey: unlocked!.signingPrivateKey! } : undefined;
|
|
517
|
+
|
|
518
|
+
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
519
|
+
const mimePart = wantEncrypt
|
|
520
|
+
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
521
|
+
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
|
|
522
|
+
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
523
|
+
return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
|
|
527
|
+
// resolves, so this is never reachable with a null `draft`.
|
|
528
|
+
async function handleSend(forcePlaintext = false) {
|
|
529
|
+
const toRecipients = parseAddresses(to);
|
|
530
|
+
if (toRecipients.length === 0) {
|
|
531
|
+
setSendError("At least one recipient is required.");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
setSending(true);
|
|
536
|
+
setSendError(null);
|
|
537
|
+
try {
|
|
538
|
+
const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
|
|
539
|
+
if (assembled === "blocked") {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
543
|
+
await sendMessage(withReceipt.uid);
|
|
544
|
+
onClose();
|
|
545
|
+
} catch (err) {
|
|
546
|
+
setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
|
|
547
|
+
} finally {
|
|
548
|
+
setSending(false);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
|
|
553
|
+
// Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
|
|
554
|
+
// (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
|
|
555
|
+
// calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
|
|
556
|
+
// instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
|
|
557
|
+
async function handleScheduleSend(scheduledSendTimeIso: string) {
|
|
558
|
+
const toRecipients = parseAddresses(to);
|
|
559
|
+
setSchedulePickerOpen(false);
|
|
560
|
+
if (toRecipients.length === 0) {
|
|
561
|
+
setSendError("At least one recipient is required.");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
setSending(true);
|
|
566
|
+
setSendError(null);
|
|
567
|
+
try {
|
|
568
|
+
const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), false);
|
|
569
|
+
if (assembled === "blocked") {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
573
|
+
const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
|
|
574
|
+
await sendMessage(scheduled.uid);
|
|
575
|
+
onClose();
|
|
576
|
+
} catch (err) {
|
|
577
|
+
setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
|
|
578
|
+
} finally {
|
|
579
|
+
setSending(false);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function handleUnlockForCrypto() {
|
|
584
|
+
try {
|
|
585
|
+
// Only reachable via the button below, which never renders unless needsUnlockForCrypto is
|
|
586
|
+
// true - which itself requires mailbox.keys to already contain a real enrolled key (see
|
|
587
|
+
// hasEnrolledSigningKey/hasEnrolledEncryptionKey), so it's never empty/undefined here either.
|
|
588
|
+
await requestUnlock(mailbox!.uid, mailbox!.keys!);
|
|
589
|
+
setUnlockRefresh((n) => n + 1);
|
|
590
|
+
} catch {
|
|
591
|
+
// User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// A plain read from keySession.ts's module-level session store, not React state - see that module's
|
|
596
|
+
// own doc comment. Cheap enough to read fresh on every render rather than caching in state.
|
|
597
|
+
const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
|
|
598
|
+
const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
|
|
599
|
+
const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
|
|
600
|
+
// This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
|
|
601
|
+
// the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
|
|
602
|
+
// without first stumbling into Mail generally (which no longer force-prompts on its own - see
|
|
603
|
+
// `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
|
|
604
|
+
// bumping `unlockRefresh` after a successful unlock is what makes this line (and the toggles it
|
|
605
|
+
// gates) reflect it on the next render.
|
|
606
|
+
const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
|
|
607
|
+
|
|
608
|
+
const title = subject.trim() || "New Message";
|
|
609
|
+
const titleId = `compose-title-${id}`;
|
|
610
|
+
|
|
611
|
+
if (minimized) {
|
|
612
|
+
return (
|
|
613
|
+
<div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
|
|
614
|
+
<div
|
|
615
|
+
className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
|
|
616
|
+
onClick={onToggleMinimize}
|
|
617
|
+
>
|
|
618
|
+
<span className="text-sm font-medium truncate">{title}</span>
|
|
619
|
+
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
620
|
+
<HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
|
|
621
|
+
<HeaderButton
|
|
622
|
+
label="Discard draft"
|
|
623
|
+
onClick={onClose}
|
|
624
|
+
icon={HiOutlineXMark}
|
|
625
|
+
/>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div
|
|
634
|
+
ref={windowRef}
|
|
635
|
+
role="dialog"
|
|
636
|
+
aria-labelledby={titleId}
|
|
637
|
+
style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
|
|
638
|
+
className={[
|
|
639
|
+
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
640
|
+
isMobile
|
|
641
|
+
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
642
|
+
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
643
|
+
].join(" ")}
|
|
644
|
+
>
|
|
645
|
+
{!isMobile && (
|
|
646
|
+
<>
|
|
647
|
+
<div
|
|
648
|
+
onPointerDown={handleResizeStart("top")}
|
|
649
|
+
aria-hidden="true"
|
|
650
|
+
className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
|
|
651
|
+
/>
|
|
652
|
+
<div
|
|
653
|
+
onPointerDown={handleResizeStart("left")}
|
|
654
|
+
aria-hidden="true"
|
|
655
|
+
className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
|
|
656
|
+
/>
|
|
657
|
+
<div
|
|
658
|
+
onPointerDown={handleResizeStart("corner")}
|
|
659
|
+
role="separator"
|
|
660
|
+
aria-label="Resize"
|
|
661
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
|
|
662
|
+
/>
|
|
663
|
+
</>
|
|
664
|
+
)}
|
|
665
|
+
|
|
666
|
+
<div
|
|
667
|
+
className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
|
|
668
|
+
onClick={onToggleMinimize}
|
|
669
|
+
>
|
|
670
|
+
<span id={titleId} className="text-sm font-medium truncate">
|
|
671
|
+
{title}
|
|
672
|
+
</span>
|
|
673
|
+
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
674
|
+
<HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
|
|
675
|
+
{!isMobile && (
|
|
676
|
+
<HeaderButton
|
|
677
|
+
label={expanded ? "Collapse" : "Expand"}
|
|
678
|
+
onClick={toggleExpanded}
|
|
679
|
+
icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
|
|
680
|
+
/>
|
|
681
|
+
)}
|
|
682
|
+
<HeaderButton label="Close" onClick={onClose} icon={HiOutlineXMark} />
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
687
|
+
{(folderError || draftError || sendError || attachError) && (
|
|
688
|
+
<div className="px-3 pt-2">
|
|
689
|
+
{folderError && <Alert>{folderError}</Alert>}
|
|
690
|
+
{draftError && <Alert>{draftError}</Alert>}
|
|
691
|
+
{sendError && <Alert>{sendError}</Alert>}
|
|
692
|
+
{attachError && <Alert>{attachError}</Alert>}
|
|
693
|
+
</div>
|
|
694
|
+
)}
|
|
695
|
+
|
|
696
|
+
{encryptionBlocked && (
|
|
697
|
+
<div className="px-3 pt-2">
|
|
698
|
+
<Alert>
|
|
699
|
+
<p className="mb-2">
|
|
700
|
+
This message can’t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
|
|
701
|
+
{encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
|
|
702
|
+
Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
|
|
703
|
+
whole message in plaintext.
|
|
704
|
+
</p>
|
|
705
|
+
<Button
|
|
706
|
+
type="button"
|
|
707
|
+
onClick={() => {
|
|
708
|
+
setEncryptionBlocked(null);
|
|
709
|
+
void handleSend(true);
|
|
710
|
+
}}
|
|
711
|
+
>
|
|
712
|
+
Send without encryption
|
|
713
|
+
</Button>
|
|
714
|
+
</Alert>
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
{mailboxes.length > 1 && (
|
|
719
|
+
<div className={FIELD_ROW}>
|
|
720
|
+
<label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
|
|
721
|
+
From
|
|
722
|
+
</label>
|
|
723
|
+
<select
|
|
724
|
+
id={`compose-from-${id}`}
|
|
725
|
+
className={`${FIELD_INPUT} disabled:opacity-55`}
|
|
726
|
+
value={mailboxUid ?? ""}
|
|
727
|
+
disabled={hasUploads || sending}
|
|
728
|
+
title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
|
|
729
|
+
onChange={(e) => handleFromChange(e.target.value)}
|
|
730
|
+
>
|
|
731
|
+
{mailboxes.map((mb) => (
|
|
732
|
+
<option key={mb.uid} value={mb.uid}>
|
|
733
|
+
{mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
|
|
734
|
+
{mb.ownerUserUid ? "" : " (shared)"}
|
|
735
|
+
</option>
|
|
736
|
+
))}
|
|
737
|
+
</select>
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
|
|
741
|
+
<div className={FIELD_ROW}>
|
|
742
|
+
<label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
|
|
743
|
+
To
|
|
744
|
+
</label>
|
|
745
|
+
<input
|
|
746
|
+
id={`compose-to-${id}`}
|
|
747
|
+
type="text"
|
|
748
|
+
className={FIELD_INPUT}
|
|
749
|
+
value={to}
|
|
750
|
+
onChange={(e) => setTo(e.target.value)}
|
|
751
|
+
onBlur={() => checkRecipientDiscovery(parseAddresses(to))}
|
|
752
|
+
/>
|
|
753
|
+
{!showCcBcc && (
|
|
754
|
+
<button
|
|
755
|
+
type="button"
|
|
756
|
+
onClick={() => setShowCcBcc(true)}
|
|
757
|
+
className="text-xs text-text-muted hover:text-text shrink-0"
|
|
758
|
+
>
|
|
759
|
+
Cc Bcc
|
|
760
|
+
</button>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
{showCcBcc && (
|
|
765
|
+
<>
|
|
766
|
+
<div className={FIELD_ROW}>
|
|
767
|
+
<label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
|
|
768
|
+
Cc
|
|
769
|
+
</label>
|
|
770
|
+
<input
|
|
771
|
+
id={`compose-cc-${id}`}
|
|
772
|
+
type="text"
|
|
773
|
+
className={FIELD_INPUT}
|
|
774
|
+
value={cc}
|
|
775
|
+
onChange={(e) => setCc(e.target.value)}
|
|
776
|
+
onBlur={() => checkRecipientDiscovery(parseAddresses(cc))}
|
|
777
|
+
/>
|
|
778
|
+
</div>
|
|
779
|
+
<div className={FIELD_ROW}>
|
|
780
|
+
<label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
|
|
781
|
+
Bcc
|
|
782
|
+
</label>
|
|
783
|
+
<input
|
|
784
|
+
id={`compose-bcc-${id}`}
|
|
785
|
+
type="text"
|
|
786
|
+
className={FIELD_INPUT}
|
|
787
|
+
value={bcc}
|
|
788
|
+
onChange={(e) => setBcc(e.target.value)}
|
|
789
|
+
onBlur={() => checkRecipientDiscovery(parseAddresses(bcc))}
|
|
790
|
+
/>
|
|
791
|
+
</div>
|
|
792
|
+
</>
|
|
793
|
+
)}
|
|
794
|
+
|
|
795
|
+
{(() => {
|
|
796
|
+
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
797
|
+
.map((r) => recipientStatuses[r.address])
|
|
798
|
+
.filter((status): status is RecipientEncryptionStatus => !!status);
|
|
799
|
+
if (knownRecipients.length === 0) {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
return (
|
|
803
|
+
<ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
|
|
804
|
+
{knownRecipients.map((status) => (
|
|
805
|
+
<li
|
|
806
|
+
key={status.address}
|
|
807
|
+
className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
|
|
808
|
+
status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
|
|
809
|
+
}`}
|
|
810
|
+
>
|
|
811
|
+
{status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
|
|
812
|
+
</li>
|
|
813
|
+
))}
|
|
814
|
+
</ul>
|
|
815
|
+
);
|
|
816
|
+
})()}
|
|
817
|
+
|
|
818
|
+
<div className={FIELD_ROW}>
|
|
819
|
+
<input
|
|
820
|
+
aria-label="Subject"
|
|
821
|
+
type="text"
|
|
822
|
+
placeholder="Subject"
|
|
823
|
+
className={FIELD_INPUT}
|
|
824
|
+
value={subject}
|
|
825
|
+
onChange={(e) => setSubject(e.target.value)}
|
|
826
|
+
/>
|
|
827
|
+
</div>
|
|
828
|
+
|
|
829
|
+
<div className="flex-1 min-h-0 p-2">
|
|
830
|
+
{contentReady && <RichTextEditor value={html} onChange={setHtml} fill onUploadImage={handleUploadImage} />}
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
{attachments.length > 0 && (
|
|
834
|
+
<ul className="flex flex-wrap gap-2 px-3 pb-2">
|
|
835
|
+
{attachments.map((attachment) => (
|
|
836
|
+
<li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
|
|
837
|
+
{attachment.filename}
|
|
838
|
+
</li>
|
|
839
|
+
))}
|
|
840
|
+
</ul>
|
|
841
|
+
)}
|
|
842
|
+
|
|
843
|
+
<label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
|
|
844
|
+
<input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
|
|
845
|
+
Request a read receipt
|
|
846
|
+
</label>
|
|
847
|
+
|
|
848
|
+
{needsUnlockForCrypto && (
|
|
849
|
+
<div className="px-3 pb-1">
|
|
850
|
+
<button
|
|
851
|
+
type="button"
|
|
852
|
+
onClick={handleUnlockForCrypto}
|
|
853
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
854
|
+
>
|
|
855
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
856
|
+
Unlock to sign or encrypt this message
|
|
857
|
+
</button>
|
|
858
|
+
</div>
|
|
859
|
+
)}
|
|
860
|
+
|
|
861
|
+
{/* Only rendered once this session actually has a usable signing/encryption key - see
|
|
862
|
+
`assembleForSend()`'s own doc comment for why signing rarely shows today (no signing
|
|
863
|
+
certificate exists until RFC 8823 ACME enrollment lands). */}
|
|
864
|
+
{unlockedKeys?.signingPrivateKey && (
|
|
865
|
+
<label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
|
|
866
|
+
<input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
|
|
867
|
+
Digitally sign this message
|
|
868
|
+
</label>
|
|
869
|
+
)}
|
|
870
|
+
{unlockedKeys?.encryptionPrivateKey && (
|
|
871
|
+
<label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
|
|
872
|
+
<input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
|
|
873
|
+
Encrypt this message
|
|
874
|
+
</label>
|
|
875
|
+
)}
|
|
876
|
+
|
|
877
|
+
<div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
|
|
878
|
+
<div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
|
|
879
|
+
<button
|
|
880
|
+
type="button"
|
|
881
|
+
onClick={() => handleSend()}
|
|
882
|
+
disabled={!draft || sending || !cryptoContextReady}
|
|
883
|
+
className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
|
|
884
|
+
>
|
|
885
|
+
{sending ? "Sending…" : "Send"}
|
|
886
|
+
</button>
|
|
887
|
+
<button
|
|
888
|
+
ref={scheduleButtonRef}
|
|
889
|
+
type="button"
|
|
890
|
+
aria-label="Send later"
|
|
891
|
+
aria-haspopup="true"
|
|
892
|
+
aria-expanded={schedulePickerOpen}
|
|
893
|
+
disabled={!draft || sending || !cryptoContextReady}
|
|
894
|
+
onClick={() => setSchedulePickerOpen((o) => !o)}
|
|
895
|
+
className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
|
|
896
|
+
>
|
|
897
|
+
<HiOutlineChevronDown size={14} />
|
|
898
|
+
</button>
|
|
899
|
+
</div>
|
|
900
|
+
{schedulePickerOpen && (
|
|
901
|
+
<ScheduleSendPicker
|
|
902
|
+
anchorRef={scheduleButtonRef}
|
|
903
|
+
onClose={() => setSchedulePickerOpen(false)}
|
|
904
|
+
onSchedule={handleScheduleSend}
|
|
905
|
+
/>
|
|
906
|
+
)}
|
|
907
|
+
|
|
908
|
+
<label
|
|
909
|
+
aria-label="Attach files"
|
|
910
|
+
title="Attach files"
|
|
911
|
+
className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed"
|
|
912
|
+
>
|
|
913
|
+
<HiOutlinePaperClip size={18} />
|
|
914
|
+
<input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
|
|
915
|
+
</label>
|
|
916
|
+
|
|
917
|
+
<button
|
|
918
|
+
type="button"
|
|
919
|
+
aria-label="Discard draft"
|
|
920
|
+
title="Discard draft"
|
|
921
|
+
onClick={onClose}
|
|
922
|
+
className="ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
923
|
+
>
|
|
924
|
+
<HiOutlineTrash size={18} />
|
|
925
|
+
</button>
|
|
926
|
+
</div>
|
|
927
|
+
</div>
|
|
928
|
+
</div>
|
|
929
|
+
);
|
|
930
|
+
}
|