@rapidmx/web-client 0.15.1 → 0.16.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/README.md +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -164
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/calendar/index.tsx +578 -571
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2479
- package/apps/www/messages/[uid].tsx +135 -135
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -248
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/calendar/index.js +3 -2
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +426 -189
- package/package.json +2 -2
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import type { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
-
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* What decides whether a new message is announced, what the announcement says, and the setting behind it (per browser, in
|
|
10
|
-
* `localStorage`): what the user told the "turn on desktop notifications" offer. Whether pop-ups are on at all is the
|
|
11
|
-
* notifications switch (`notifications/preferences.ts`).
|
|
12
|
-
*
|
|
13
|
-
* The announcement is built only from what the push event carries - the whole `Message`, including `bodyPreview` - and only
|
|
14
|
-
* ever as text: React escapes it on screen and the Notifications API takes plain strings, so nothing here is HTML.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** `localStorage` key of the answer to the desktop-notifications offer: `"later"` (not now) or `"asked"` (they were asked). */
|
|
18
|
-
export const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
|
|
19
|
-
|
|
20
|
-
/** How much of the body an announcement quotes. */
|
|
21
|
-
export const PREVIEW_MAX_LENGTH = 140;
|
|
22
|
-
|
|
23
|
-
/** A message received longer ago than this is not "new": mail imported or migrated in bulk arrives as `create` events too. */
|
|
24
|
-
export const MAX_NEW_AGE_MS = 6 * 60 * 60 * 1000;
|
|
25
|
-
|
|
26
|
-
/** The subject the server stores for an encrypted message (the real one is inside the ciphertext). */
|
|
27
|
-
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
28
|
-
|
|
29
|
-
function readStorage(key: string): string | null {
|
|
30
|
-
try {
|
|
31
|
-
return localStorage.getItem(key);
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeStorage(key: string, value: string | null): void {
|
|
38
|
-
try {
|
|
39
|
-
if (value === null) {
|
|
40
|
-
localStorage.removeItem(key);
|
|
41
|
-
} else {
|
|
42
|
-
localStorage.setItem(key, value);
|
|
43
|
-
}
|
|
44
|
-
} catch {
|
|
45
|
-
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
|
|
50
|
-
export function getDesktopOfferDismissed(): boolean {
|
|
51
|
-
return readStorage(DESKTOP_OFFER_KEY) !== null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function setDesktopOfferDismissed(dismissed: boolean): void {
|
|
55
|
-
writeStorage(DESKTOP_OFFER_KEY, dismissed ? "later" : null);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The browser's notification permission, or `"unsupported"` where there is no Notifications API. */
|
|
59
|
-
export type DesktopPermission = NotificationPermission | "unsupported";
|
|
60
|
-
|
|
61
|
-
export function desktopPermission(): DesktopPermission {
|
|
62
|
-
return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
|
|
66
|
-
export async function requestDesktopPermission(): Promise<DesktopPermission> {
|
|
67
|
-
if (typeof Notification === "undefined") {
|
|
68
|
-
return "unsupported";
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
return await Notification.requestPermission();
|
|
72
|
-
} catch {
|
|
73
|
-
return desktopPermission();
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** What an announcement shows. */
|
|
78
|
-
export interface NewMailNotice {
|
|
79
|
-
/** The message's uid: the announcement's identity (and the desktop notification's `tag`). */
|
|
80
|
-
uid: string;
|
|
81
|
-
mailboxUid: string;
|
|
82
|
-
folderUid: string;
|
|
83
|
-
/** The sender's display name, `""` when there is none. */
|
|
84
|
-
senderName: string;
|
|
85
|
-
/** The sender's address - always shown, so a name can't stand in for who really sent it. */
|
|
86
|
-
senderAddress: string;
|
|
87
|
-
subject: string;
|
|
88
|
-
/** About `PREVIEW_MAX_LENGTH` characters of the body as plain text; `Encrypted message` for an encrypted one. */
|
|
89
|
-
preview: string;
|
|
90
|
-
/** Where opening it goes. */
|
|
91
|
-
href: string;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Control characters, and the invisible or bidirectional-override characters that can make text read as something else, as code point ranges. */
|
|
95
|
-
const UNWANTED_RANGES: [number, number][] = [
|
|
96
|
-
[0x0000, 0x001f],
|
|
97
|
-
[0x007f, 0x009f],
|
|
98
|
-
[0x200b, 0x200f],
|
|
99
|
-
[0x2028, 0x202e],
|
|
100
|
-
[0x2060, 0x2064],
|
|
101
|
-
[0x2066, 0x2069],
|
|
102
|
-
[0xfeff, 0xfeff],
|
|
103
|
-
];
|
|
104
|
-
const hex = (code: number) => "\\u" + code.toString(16).padStart(4, "0");
|
|
105
|
-
const UNWANTED = new RegExp("[" + UNWANTED_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]+", "g");
|
|
106
|
-
|
|
107
|
-
/** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
|
|
108
|
-
export function cleanPreview(text: string, max: number = PREVIEW_MAX_LENGTH): string {
|
|
109
|
-
const cleaned = text.replace(UNWANTED, " ").replace(/\s+/g, " ").trim();
|
|
110
|
-
return cleaned.length > max ? cleaned.slice(0, max - 1).trimEnd() + String.fromCharCode(0x2026) : cleaned;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The text of an announcement of `message`. */
|
|
114
|
-
export function noticeFor(message: Message): NewMailNotice {
|
|
115
|
-
const { name, address } = splitMailAddress(message.from);
|
|
116
|
-
const encrypted = message.encrypted === true;
|
|
117
|
-
const rawSubject = cleanPreview(message.subject ?? "", 200);
|
|
118
|
-
const subject =
|
|
119
|
-
!rawSubject || rawSubject === ENCRYPTED_SUBJECT_PLACEHOLDER ? (encrypted ? "(encrypted subject)" : "(no subject)") : rawSubject;
|
|
120
|
-
return {
|
|
121
|
-
uid: message.uid,
|
|
122
|
-
mailboxUid: message.mailboxUid,
|
|
123
|
-
folderUid: message.folderUid,
|
|
124
|
-
// `splitMailAddress()` has already taken control and bidirectional-override characters out of both.
|
|
125
|
-
senderName: name ?? "",
|
|
126
|
-
senderAddress: address,
|
|
127
|
-
subject,
|
|
128
|
-
preview: encrypted ? "Encrypted message" : cleanPreview(message.bodyPreview ?? ""),
|
|
129
|
-
href: `/messages/${encodeURIComponent(message.uid)}`,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
|
|
134
|
-
export function ownAddressesOf(mailboxes: Mailbox[]): Set<string> {
|
|
135
|
-
const own = new Set<string>();
|
|
136
|
-
for (const mailbox of mailboxes) {
|
|
137
|
-
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
138
|
-
if (address) {
|
|
139
|
-
own.add(address.trim().toLowerCase());
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return own;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
|
|
148
|
-
* Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
|
|
149
|
-
* send, and that is not old (a bulk import).
|
|
150
|
-
*/
|
|
151
|
-
export function shouldAnnounce(
|
|
152
|
-
message: Message,
|
|
153
|
-
context: { folders: Folder[]; ownAddresses: Set<string>; now?: number },
|
|
154
|
-
): boolean {
|
|
155
|
-
if (context.folders.find((folder) => folder.uid === message.folderUid)?.type !== "inbox") {
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
if (message.flags.read === true || message.inferenceClassification === "other") {
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
if (context.ownAddresses.has((message.from?.address ?? "").trim().toLowerCase())) {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
const received = Date.parse(message.receivedDate);
|
|
165
|
-
return Number.isNaN(received) || (context.now ?? Date.now()) - received < MAX_NEW_AGE_MS;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** `Name <address>`, or the address alone - the desktop notification's title. */
|
|
169
|
-
export function noticeSender(notice: Pick<NewMailNotice, "senderName" | "senderAddress">): string {
|
|
170
|
-
return formatMailAddress({ address: notice.senderAddress, displayName: notice.senderName || undefined });
|
|
171
|
-
}
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What decides whether a new message is announced, what the announcement says, and the setting behind it (per browser, in
|
|
10
|
+
* `localStorage`): what the user told the "turn on desktop notifications" offer. Whether pop-ups are on at all is the
|
|
11
|
+
* notifications switch (`notifications/preferences.ts`).
|
|
12
|
+
*
|
|
13
|
+
* The announcement is built only from what the push event carries - the whole `Message`, including `bodyPreview` - and only
|
|
14
|
+
* ever as text: React escapes it on screen and the Notifications API takes plain strings, so nothing here is HTML.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `localStorage` key of the answer to the desktop-notifications offer: `"later"` (not now) or `"asked"` (they were asked). */
|
|
18
|
+
export const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
|
|
19
|
+
|
|
20
|
+
/** How much of the body an announcement quotes. */
|
|
21
|
+
export const PREVIEW_MAX_LENGTH = 140;
|
|
22
|
+
|
|
23
|
+
/** A message received longer ago than this is not "new": mail imported or migrated in bulk arrives as `create` events too. */
|
|
24
|
+
export const MAX_NEW_AGE_MS = 6 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** The subject the server stores for an encrypted message (the real one is inside the ciphertext). */
|
|
27
|
+
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
28
|
+
|
|
29
|
+
function readStorage(key: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return localStorage.getItem(key);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeStorage(key: string, value: string | null): void {
|
|
38
|
+
try {
|
|
39
|
+
if (value === null) {
|
|
40
|
+
localStorage.removeItem(key);
|
|
41
|
+
} else {
|
|
42
|
+
localStorage.setItem(key, value);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
|
|
50
|
+
export function getDesktopOfferDismissed(): boolean {
|
|
51
|
+
return readStorage(DESKTOP_OFFER_KEY) !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setDesktopOfferDismissed(dismissed: boolean): void {
|
|
55
|
+
writeStorage(DESKTOP_OFFER_KEY, dismissed ? "later" : null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The browser's notification permission, or `"unsupported"` where there is no Notifications API. */
|
|
59
|
+
export type DesktopPermission = NotificationPermission | "unsupported";
|
|
60
|
+
|
|
61
|
+
export function desktopPermission(): DesktopPermission {
|
|
62
|
+
return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
|
|
66
|
+
export async function requestDesktopPermission(): Promise<DesktopPermission> {
|
|
67
|
+
if (typeof Notification === "undefined") {
|
|
68
|
+
return "unsupported";
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return await Notification.requestPermission();
|
|
72
|
+
} catch {
|
|
73
|
+
return desktopPermission();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What an announcement shows. */
|
|
78
|
+
export interface NewMailNotice {
|
|
79
|
+
/** The message's uid: the announcement's identity (and the desktop notification's `tag`). */
|
|
80
|
+
uid: string;
|
|
81
|
+
mailboxUid: string;
|
|
82
|
+
folderUid: string;
|
|
83
|
+
/** The sender's display name, `""` when there is none. */
|
|
84
|
+
senderName: string;
|
|
85
|
+
/** The sender's address - always shown, so a name can't stand in for who really sent it. */
|
|
86
|
+
senderAddress: string;
|
|
87
|
+
subject: string;
|
|
88
|
+
/** About `PREVIEW_MAX_LENGTH` characters of the body as plain text; `Encrypted message` for an encrypted one. */
|
|
89
|
+
preview: string;
|
|
90
|
+
/** Where opening it goes. */
|
|
91
|
+
href: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Control characters, and the invisible or bidirectional-override characters that can make text read as something else, as code point ranges. */
|
|
95
|
+
const UNWANTED_RANGES: [number, number][] = [
|
|
96
|
+
[0x0000, 0x001f],
|
|
97
|
+
[0x007f, 0x009f],
|
|
98
|
+
[0x200b, 0x200f],
|
|
99
|
+
[0x2028, 0x202e],
|
|
100
|
+
[0x2060, 0x2064],
|
|
101
|
+
[0x2066, 0x2069],
|
|
102
|
+
[0xfeff, 0xfeff],
|
|
103
|
+
];
|
|
104
|
+
const hex = (code: number) => "\\u" + code.toString(16).padStart(4, "0");
|
|
105
|
+
const UNWANTED = new RegExp("[" + UNWANTED_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]+", "g");
|
|
106
|
+
|
|
107
|
+
/** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
|
|
108
|
+
export function cleanPreview(text: string, max: number = PREVIEW_MAX_LENGTH): string {
|
|
109
|
+
const cleaned = text.replace(UNWANTED, " ").replace(/\s+/g, " ").trim();
|
|
110
|
+
return cleaned.length > max ? cleaned.slice(0, max - 1).trimEnd() + String.fromCharCode(0x2026) : cleaned;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The text of an announcement of `message`. */
|
|
114
|
+
export function noticeFor(message: Message): NewMailNotice {
|
|
115
|
+
const { name, address } = splitMailAddress(message.from);
|
|
116
|
+
const encrypted = message.encrypted === true;
|
|
117
|
+
const rawSubject = cleanPreview(message.subject ?? "", 200);
|
|
118
|
+
const subject =
|
|
119
|
+
!rawSubject || rawSubject === ENCRYPTED_SUBJECT_PLACEHOLDER ? (encrypted ? "(encrypted subject)" : "(no subject)") : rawSubject;
|
|
120
|
+
return {
|
|
121
|
+
uid: message.uid,
|
|
122
|
+
mailboxUid: message.mailboxUid,
|
|
123
|
+
folderUid: message.folderUid,
|
|
124
|
+
// `splitMailAddress()` has already taken control and bidirectional-override characters out of both.
|
|
125
|
+
senderName: name ?? "",
|
|
126
|
+
senderAddress: address,
|
|
127
|
+
subject,
|
|
128
|
+
preview: encrypted ? "Encrypted message" : cleanPreview(message.bodyPreview ?? ""),
|
|
129
|
+
href: `/messages/${encodeURIComponent(message.uid)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
|
|
134
|
+
export function ownAddressesOf(mailboxes: Mailbox[]): Set<string> {
|
|
135
|
+
const own = new Set<string>();
|
|
136
|
+
for (const mailbox of mailboxes) {
|
|
137
|
+
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
138
|
+
if (address) {
|
|
139
|
+
own.add(address.trim().toLowerCase());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return own;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
|
|
148
|
+
* Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
|
|
149
|
+
* send, and that is not old (a bulk import).
|
|
150
|
+
*/
|
|
151
|
+
export function shouldAnnounce(
|
|
152
|
+
message: Message,
|
|
153
|
+
context: { folders: Folder[]; ownAddresses: Set<string>; now?: number },
|
|
154
|
+
): boolean {
|
|
155
|
+
if (context.folders.find((folder) => folder.uid === message.folderUid)?.type !== "inbox") {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (message.flags.read === true || message.inferenceClassification === "other") {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (context.ownAddresses.has((message.from?.address ?? "").trim().toLowerCase())) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const received = Date.parse(message.receivedDate);
|
|
165
|
+
return Number.isNaN(received) || (context.now ?? Date.now()) - received < MAX_NEW_AGE_MS;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `Name <address>`, or the address alone - the desktop notification's title. */
|
|
169
|
+
export function noticeSender(notice: Pick<NewMailNotice, "senderName" | "senderAddress">): string {
|
|
170
|
+
return formatMailAddress({ address: notice.senderAddress, displayName: notice.senderName || undefined });
|
|
171
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import type { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
8
|
+
import type { NotificationAction } from "../../notifications/store.js";
|
|
9
|
+
import type { SendRequest } from "./sendJob.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A reply (or forward) this tab has sent, as the open conversation shows it until the server's own copy is in the thread.
|
|
13
|
+
*
|
|
14
|
+
* This is a *view* of the background send that is already there, not a second state machine: `startSend()` (`sendJob.ts`) opens an entry, and the
|
|
15
|
+
* places that already report how a send went close it - the client-side failure (`notifyFailure()`), the server's own outcome (`handleSendEvent()`
|
|
16
|
+
* in `sendOutcomes.ts`: `send-succeeded`, `send-failed`) and a synchronous send. The Outbox indicator, the pop-ups and this entry therefore agree,
|
|
17
|
+
* because they are all told by the same code at the same moment.
|
|
18
|
+
*
|
|
19
|
+
* Why the message has to be shown from here for a while: a message the server accepted is in Outbox, which has no `conversationId` yet - it is
|
|
20
|
+
* resolved when the message is relayed and filed in Sent Items (`ScheduledSendJob`), keeping the same `uid`. Until then no fetch of the
|
|
21
|
+
* conversation can return it, so the thread pane draws it from what was composed.
|
|
22
|
+
*
|
|
23
|
+
* `sending` covers everything up to the server's answer (assembling, the request, the queue in Outbox); `failed` is a send that did not go and is
|
|
24
|
+
* the sender's to resolve (`actions` are the very ones the pop-up offers: Retry, Open draft ...); `sent` is a message the server has relayed and
|
|
25
|
+
* that is waiting for the thread to be read again to find its Sent Items copy.
|
|
26
|
+
*/
|
|
27
|
+
export type OutgoingState = "sending" | "failed" | "sent";
|
|
28
|
+
|
|
29
|
+
export interface OutgoingReply {
|
|
30
|
+
/** The draft's uid - which is also the uid of the message in Outbox and of its Sent Items copy, and so what the real message replaces this by. */
|
|
31
|
+
uid: string;
|
|
32
|
+
mailboxUid: string;
|
|
33
|
+
/** What the message replies to (`Message.inReplyTo` / `references`): how the pane knows which conversation it belongs to. */
|
|
34
|
+
inReplyTo?: string;
|
|
35
|
+
references: string[];
|
|
36
|
+
sender: MailAddressLike;
|
|
37
|
+
to: MailAddressLike[];
|
|
38
|
+
cc: MailAddressLike[];
|
|
39
|
+
bcc: MailAddressLike[];
|
|
40
|
+
subject: string;
|
|
41
|
+
/** The composed body, as the editor wrote it. */
|
|
42
|
+
html: string;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
state: OutgoingState;
|
|
45
|
+
/** `failed`: why. */
|
|
46
|
+
failure?: string;
|
|
47
|
+
/** `failed`: what the sender can do about it. */
|
|
48
|
+
actions: NotificationAction[];
|
|
49
|
+
/** `sent`: how many times the thread was read without finding the message - see `settleOutgoing()`. */
|
|
50
|
+
misses: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The most entries kept; the oldest go first. Every entry is a message this tab sent, so this is generous. */
|
|
54
|
+
const MAX_TRACKED = 25;
|
|
55
|
+
/** A sent message that a read of its conversation has not found this many times is not part of that conversation (the server filed it elsewhere). */
|
|
56
|
+
export const MAX_SETTLE_MISSES = 2;
|
|
57
|
+
|
|
58
|
+
const NO_REPLIES: readonly OutgoingReply[] = [];
|
|
59
|
+
let replies: OutgoingReply[] = [];
|
|
60
|
+
let snapshot: readonly OutgoingReply[] = replies;
|
|
61
|
+
const listeners = new Set<() => void>();
|
|
62
|
+
|
|
63
|
+
function commit(next: OutgoingReply[]): void {
|
|
64
|
+
replies = next;
|
|
65
|
+
snapshot = next;
|
|
66
|
+
for (const listener of [...listeners]) {
|
|
67
|
+
listener();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function update(uid: string, patch: Partial<OutgoingReply>): void {
|
|
72
|
+
if (replies.some((reply) => reply.uid === uid)) {
|
|
73
|
+
commit(replies.map((reply) => (reply.uid === uid ? { ...reply, ...patch } : reply)));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Starts showing `request`'s message in the conversation it replies to - or does nothing for a message that replies to nothing (a new message
|
|
79
|
+
* starts a conversation of its own, it is never part of an open one) and for a send-later message (it is not sent yet). Called again for the same
|
|
80
|
+
* message - a Retry - it starts over as `sending`.
|
|
81
|
+
*
|
|
82
|
+
* The thread is `request.threading` (the compose session's), else what the draft itself records: a draft opened again from "Open draft" is a
|
|
83
|
+
* session with no threading of its own, but the server kept it on the message.
|
|
84
|
+
*/
|
|
85
|
+
export function trackOutgoing(request: SendRequest): void {
|
|
86
|
+
const source = request.threading ?? request.draft;
|
|
87
|
+
const references = source.references ?? [];
|
|
88
|
+
if (request.scheduledSendTime || (!source.inReplyTo && references.length === 0)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { mailbox } = request;
|
|
92
|
+
const reply: OutgoingReply = {
|
|
93
|
+
uid: request.draft.uid,
|
|
94
|
+
mailboxUid: request.mailboxUid,
|
|
95
|
+
inReplyTo: source.inReplyTo,
|
|
96
|
+
references,
|
|
97
|
+
// The mailbox is only unknown when the window's own load of it had not succeeded: "Me" is what a sender is called then.
|
|
98
|
+
sender: mailbox ? { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName } : { address: "Me" },
|
|
99
|
+
to: request.to,
|
|
100
|
+
cc: request.cc,
|
|
101
|
+
bcc: request.bcc,
|
|
102
|
+
subject: request.subject,
|
|
103
|
+
html: request.html,
|
|
104
|
+
startedAt: Date.now(),
|
|
105
|
+
state: "sending",
|
|
106
|
+
actions: [],
|
|
107
|
+
misses: 0,
|
|
108
|
+
};
|
|
109
|
+
commit([...replies.filter((existing) => existing.uid !== reply.uid), reply].slice(-MAX_TRACKED));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The send did not go: `failure` is why, `actions` what can be done (Retry, Open draft ...). */
|
|
113
|
+
export function failOutgoing(uid: string, failure: string, actions: NotificationAction[]): void {
|
|
114
|
+
update(uid, { state: "failed", failure, actions });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A failed send is being tried again. */
|
|
118
|
+
export function markOutgoingSending(uid: string): void {
|
|
119
|
+
update(uid, { state: "sending", failure: undefined, actions: [] });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The server has relayed the message: its Sent Items copy is what the conversation shows next. */
|
|
123
|
+
export function markOutgoingSent(uid: string): void {
|
|
124
|
+
update(uid, { state: "sent" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Stops showing a message - it went back to Drafts to be edited. */
|
|
128
|
+
export function forgetOutgoing(uid: string): void {
|
|
129
|
+
if (replies.some((reply) => reply.uid === uid)) {
|
|
130
|
+
commit(replies.filter((reply) => reply.uid !== uid));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether `reply` continues the conversation `messages` are: it answers one of them, or its `References` chain runs through one. */
|
|
135
|
+
export function belongsToThread(reply: OutgoingReply, messages: readonly Pick<Message, "messageId">[]): boolean {
|
|
136
|
+
return messages.some((message) => !!message.messageId && (message.messageId === reply.inReplyTo || reply.references.includes(message.messageId)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The uids of the messages sent from here whose real copy is in `messages`, the thread as it was just read. */
|
|
140
|
+
export function adoptedOutgoing(mailboxUid: string, messages: readonly Message[]): string[] {
|
|
141
|
+
return replies.filter((reply) => reply.mailboxUid === mailboxUid && messages.some((message) => message.uid === reply.uid)).map((reply) => reply.uid);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Called with a mailbox's conversation as it was just read, `messages` being the whole thread: forgets the messages whose real copy is now in it
|
|
146
|
+
* (`adoptedOutgoing()`), and gives up on a *sent* message that belongs to this thread but that the read did not find `MAX_SETTLE_MISSES` times in a
|
|
147
|
+
* row - the server filed it in a conversation of its own, and it is not to be shown here. A message of another conversation is left alone.
|
|
148
|
+
*
|
|
149
|
+
* A pane draws the messages it has read *before* it calls this, so that there is never a moment with neither the pending card nor the real one.
|
|
150
|
+
*/
|
|
151
|
+
export function settleOutgoing(mailboxUid: string, messages: readonly Message[]): void {
|
|
152
|
+
const adopted = new Set(adoptedOutgoing(mailboxUid, messages));
|
|
153
|
+
const next: OutgoingReply[] = [];
|
|
154
|
+
let changed = adopted.size > 0;
|
|
155
|
+
for (const reply of replies) {
|
|
156
|
+
if (adopted.has(reply.uid)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (reply.mailboxUid === mailboxUid && reply.state === "sent" && belongsToThread(reply, messages)) {
|
|
160
|
+
changed = true;
|
|
161
|
+
if (reply.misses + 1 < MAX_SETTLE_MISSES) {
|
|
162
|
+
next.push({ ...reply, misses: reply.misses + 1 });
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
next.push(reply);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (changed) {
|
|
169
|
+
commit(next);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getOutgoingReplies(): readonly OutgoingReply[] {
|
|
174
|
+
return snapshot;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function subscribe(listener: () => void): () => void {
|
|
178
|
+
listeners.add(listener);
|
|
179
|
+
return () => {
|
|
180
|
+
listeners.delete(listener);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The messages this tab has sent that a conversation may still need to show, oldest first. */
|
|
185
|
+
export function useOutgoingReplies(): readonly OutgoingReply[] {
|
|
186
|
+
return useSyncExternalStore(subscribe, getOutgoingReplies, () => NO_REPLIES);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Forgets everything - for a test. */
|
|
190
|
+
export function resetOutgoingReplies(): void {
|
|
191
|
+
commit([]);
|
|
192
|
+
}
|
|
@@ -6,6 +6,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
6
6
|
import {
|
|
7
7
|
Attachment,
|
|
8
8
|
ComposeRecipientInput,
|
|
9
|
+
DraftThreading,
|
|
9
10
|
Mailbox,
|
|
10
11
|
Message,
|
|
11
12
|
assembleDraft,
|
|
@@ -26,6 +27,7 @@ import { policyCanAutoEncrypt } from "../../components/mail/compose/encryptionRe
|
|
|
26
27
|
import { NotificationAction, dismiss, notify } from "../../notifications/store.js";
|
|
27
28
|
import { INLINE_IMAGE_PATTERN, SendBlock, SendDecisionInput, decideSend } from "./sendDecision.js";
|
|
28
29
|
import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
|
|
30
|
+
import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent, trackOutgoing } from "./outgoingReplies.js";
|
|
29
31
|
import { openComposeFromOutside, requestUnlockFromOutside } from "./composeBridge.js";
|
|
30
32
|
import { OutboxCountTracker, sendState } from "./sendState.js";
|
|
31
33
|
|
|
@@ -60,6 +62,10 @@ export interface SendRequest {
|
|
|
60
62
|
forcePlaintext: boolean;
|
|
61
63
|
/** Settles when the window's own autosaves have landed, so none can overwrite the assembly below. */
|
|
62
64
|
saved?: Promise<unknown>;
|
|
65
|
+
/** The thread this message continues (a reply or forward), as the compose session was opened with it - what lets the conversation on screen
|
|
66
|
+
* show the message as soon as it is sent (see `outgoingReplies.ts`). Absent for a new message, and for a draft opened again, which the draft's own
|
|
67
|
+
* `inReplyTo`/`references` stand in for. */
|
|
68
|
+
threading?: DraftThreading;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
/** How long a background send waits for the mailbox / encryption policy it did not have, before it goes ahead treating them as unknown. Nobody waits for it. */
|
|
@@ -311,7 +317,7 @@ export function describeMessage(subject: string, recipients: string): string {
|
|
|
311
317
|
|
|
312
318
|
/** Re-opens the compose window around a request's message, with everything as it was typed. */
|
|
313
319
|
export function openDraftFromRequest(request: SendRequest): boolean {
|
|
314
|
-
|
|
320
|
+
const opened = openComposeFromOutside({
|
|
315
321
|
mailboxUid: request.mailboxUid,
|
|
316
322
|
resume: {
|
|
317
323
|
draft: request.draft,
|
|
@@ -327,6 +333,11 @@ export function openDraftFromRequest(request: SendRequest): boolean {
|
|
|
327
333
|
encryptRequested: request.encryptRequested,
|
|
328
334
|
},
|
|
329
335
|
});
|
|
336
|
+
// Being edited again, it is no longer a message on its way in the conversation.
|
|
337
|
+
if (opened) {
|
|
338
|
+
forgetOutgoing(request.draft.uid);
|
|
339
|
+
}
|
|
340
|
+
return opened;
|
|
330
341
|
}
|
|
331
342
|
|
|
332
343
|
function failureNotificationId(draftUid: string): string {
|
|
@@ -363,6 +374,8 @@ function notifyFailure(request: SendRequest, failure: Failure): void {
|
|
|
363
374
|
details: failure.details,
|
|
364
375
|
actions,
|
|
365
376
|
});
|
|
377
|
+
// The conversation on screen shows the same failure under the message, with the same ways out.
|
|
378
|
+
failOutgoing(uid, failure.message, actions);
|
|
366
379
|
}
|
|
367
380
|
|
|
368
381
|
const SENT_BURST_MS = 6_000;
|
|
@@ -389,6 +402,8 @@ export function startSend(request: SendRequest): boolean {
|
|
|
389
402
|
}
|
|
390
403
|
// The failure of an earlier attempt is resolved by trying again.
|
|
391
404
|
dismiss(failureNotificationId(uid));
|
|
405
|
+
// A reply or forward is on screen in its conversation from this moment, before any of the work below.
|
|
406
|
+
trackOutgoing(request);
|
|
392
407
|
const tracker = sendState.countTracker?.(request.mailboxUid);
|
|
393
408
|
void attempt(request).then((outcome) => {
|
|
394
409
|
finishPendingSend(uid);
|
|
@@ -402,6 +417,8 @@ export function startSend(request: SendRequest): boolean {
|
|
|
402
417
|
retain(request);
|
|
403
418
|
sendState.queuedListener?.(request.mailboxUid);
|
|
404
419
|
} else if (outcome.kind === "sent") {
|
|
420
|
+
// A server with no queue relayed it before answering: the Sent Items copy exists already.
|
|
421
|
+
markOutgoingSent(uid);
|
|
405
422
|
notifySent();
|
|
406
423
|
} else {
|
|
407
424
|
notify({
|
|
@@ -420,6 +437,7 @@ export function startSend(request: SendRequest): boolean {
|
|
|
420
437
|
export async function retrySend(request: SendRequest): Promise<void> {
|
|
421
438
|
if (await alreadyAccepted(request)) {
|
|
422
439
|
dismiss(failureNotificationId(request.draft.uid));
|
|
440
|
+
markOutgoingSending(request.draft.uid);
|
|
423
441
|
notify({ id: `already-sent:${request.draft.uid}`, kind: "info", title: "This message is already on its way", timeoutMs: 5_000 });
|
|
424
442
|
return;
|
|
425
443
|
}
|