@rapidmx/web-client 0.22.0 → 0.23.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.
Files changed (32) hide show
  1. package/apps/shared/components/admin/setup/SetupWizard.tsx +1 -1
  2. package/apps/shared/components/calendar/TimeGridView.tsx +3 -1
  3. package/apps/shared/components/mail/ConversationList.tsx +18 -5
  4. package/apps/shared/components/mail/ConversationThreadPane.tsx +7 -2
  5. package/apps/shared/components/mail/MessageDetailPane.tsx +37 -18
  6. package/apps/shared/components/mail/invite/InviteRowChip.tsx +8 -12
  7. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +2 -2
  8. package/apps/shared/components/mail/reading/conversationPrint.ts +76 -0
  9. package/apps/shared/components/mail/reading/printMessage.ts +44 -7
  10. package/apps/shared/keyboard/keymap.ts +1 -0
  11. package/apps/shared/mail/decryptedMessages.ts +116 -0
  12. package/apps/www/index.tsx +11 -0
  13. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +4 -4
  14. package/dist/apps/shared/components/admin/setup/SetupWizard.js +1 -1
  15. package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
  16. package/dist/apps/shared/components/mail/ConversationList.js +17 -4
  17. package/dist/apps/shared/components/mail/ConversationThreadPane.js +6 -2
  18. package/dist/apps/shared/components/mail/MessageDetailPane.js +31 -18
  19. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +2 -2
  20. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +7 -9
  21. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
  22. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +2 -2
  23. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +15 -0
  24. package/dist/apps/shared/components/mail/reading/conversationPrint.js +69 -0
  25. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +7 -1
  26. package/dist/apps/shared/components/mail/reading/printMessage.js +33 -6
  27. package/dist/apps/shared/keyboard/keymap.d.ts +6 -0
  28. package/dist/apps/shared/keyboard/keymap.js +1 -0
  29. package/dist/apps/shared/mail/decryptedMessages.d.ts +27 -0
  30. package/dist/apps/shared/mail/decryptedMessages.js +97 -0
  31. package/dist/apps/www/index.js +10 -0
  32. package/package.json +1 -1
@@ -16,7 +16,7 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
16
16
  const timedEvents = occurrences.filter((occ) => !occ.allDay);
17
17
  return (_jsxs("div", { "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
18
18
  .filter((occ) => occursOnDay(occ, day))
19
- .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
19
+ .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "relative -top-1.5 text-[10px] text-text-muted text-right pr-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
20
20
  }
21
21
  function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
22
22
  const dayStart = startOfDay(day);
@@ -3,12 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../lib/util/api.js";
9
9
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
10
10
  import MailAddress from "./MailAddress.js";
11
- import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
11
+ import { EncryptedPreview, conversationLooksEncrypted, displaySubject } from "./reading/EncryptedPreview.js";
12
+ import { decryptForDisplay, useDecryptedMessages, useUnlockEpoch } from "../../mail/decryptedMessages.js";
12
13
  import { listConversationMessages } from "../../../../lib/mail/conversationsApi.js";
13
14
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
14
15
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
@@ -37,6 +38,18 @@ function participantList(conversation) {
37
38
  */
38
39
  export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }) {
39
40
  const client = useApiClient();
41
+ // What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
42
+ const decrypted = useDecryptedMessages();
43
+ const unlockEpoch = useUnlockEpoch();
44
+ // An encrypted conversation's row stands for its latest message, whose subject only decrypting can tell. Rows are decrypted as they are listed
45
+ // whenever the mailbox is unlocked (and again once it is), the same way the message list does.
46
+ useEffect(() => {
47
+ for (const conversation of conversations) {
48
+ if (conversationLooksEncrypted(conversation)) {
49
+ void decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
50
+ }
51
+ }
52
+ }, [conversations, mailboxUid, unlockEpoch]);
40
53
  const [expanded, setExpanded] = useState(new Set());
41
54
  const [messagesById, setMessagesById] = useState({});
42
55
  const [loadingIds, setLoadingIds] = useState(new Set());
@@ -92,7 +105,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
92
105
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
93
106
  const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
94
107
  const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
95
- return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
108
+ return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
96
109
  uid: conversation.latestMessageUid,
97
110
  meetingMethod: conversation.latestMeetingMethod ?? undefined,
98
111
  meetingResponse: answers[conversation.latestMessageUid] ??
@@ -102,7 +115,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
102
115
  }, onResponded: answered, className: "mr-4" })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
103
116
  const message = messageOverrides?.[fetched.uid] ?? fetched;
104
117
  const messageUnread = isUnread(message);
105
- return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
118
+ return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || decrypted[message.uid]?.preview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
106
119
  answered(updated);
107
120
  onMeetingResponded?.(updated);
108
121
  }, className: "ml-8 mr-4" })] }, message.uid));
@@ -11,7 +11,8 @@ import { listConversationMessages } from "../../../../lib/mail/conversationsApi.
11
11
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
12
  import Alert from "../../../../lib/components/feedback/Alert.js";
13
13
  import MessageDetailPane from "./MessageDetailPane.js";
14
- import { EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
14
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
15
+ import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
15
16
  import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
16
17
  import PendingMessageCard from "./reading/PendingMessageCard.js";
17
18
  import { useMailShell } from "./layout/MailShell.js";
@@ -127,6 +128,7 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
127
128
  const client = useApiClient();
128
129
  // The replies and forwards this tab has sent, drawn at the top of the thread they continue until the server's own copy is in it.
129
130
  const outgoing = useOutgoingReplies();
131
+ const decryptedMessages = useDecryptedMessages();
130
132
  const [messages, setMessages] = useState([]);
131
133
  const [attachmentsByUid, setAttachmentsByUid] = useState({});
132
134
  const [expandedUids, setExpandedUids] = useState(new Set());
@@ -423,7 +425,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
423
425
  if (!conversation) {
424
426
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
425
427
  }
426
- const subject = displaySubject(conversation.subject) || "(no subject)";
428
+ // An encrypted thread's subject is the placeholder until one of its messages has been decrypted here; the newest one that has says it.
429
+ const decryptedSubject = conversation.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? messages.map((message) => decryptedMessages[message.uid]?.subject).find(Boolean) : undefined;
430
+ const subject = (decryptedSubject ?? displaySubject(conversation.subject)) || "(no subject)";
427
431
  // The newest message that is open carries the Reply / Forward buttons at the foot of its card.
428
432
  const footerUid = messages.find((message) => expandedUids.has(message.uid))?.uid;
429
433
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col", children: [_jsx(SubjectCard, { subject: subject, meta: loading
@@ -43,14 +43,16 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
43
43
  import LabelMenuButton from "./labelMenu.js";
44
44
  import Button from "../../../../lib/components/buttons/Button.js";
45
45
  import EncryptedBody from "./reading/EncryptedBody.js";
46
- import { displaySubject } from "./reading/EncryptedPreview.js";
46
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
47
+ import { rememberDecrypted } from "../../mail/decryptedMessages.js";
47
48
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
48
49
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
49
50
  import MessageMoreMenu from "./reading/MessageMoreMenu.js";
50
51
  import MessageSourceDialog from "./reading/MessageSourceDialog.js";
51
52
  import { useMessageActions } from "./reading/useMessageActions.js";
52
53
  import { saveAsEml } from "./reading/messageExport.js";
53
- import { buildPrintDocument, printDocument } from "./reading/printMessage.js";
54
+ import { buildConversationPrintDocument, printDocument } from "./reading/printMessage.js";
55
+ import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
54
56
  import { fetchBodyContent } from "./reading/bodyContent.js";
55
57
  import { useViewOriginal } from "./reading/viewOriginal.js";
56
58
  import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
@@ -781,7 +783,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
781
783
  const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
782
784
  ? senderKeyState.conflict
783
785
  : undefined;
784
- const shownSubject = (protectedSubject ?? displaySubject(message.subject)) || "(no subject)";
786
+ // An encrypted message's outer Subject is only RFC 9788's placeholder: whatever decrypting recovered is the subject, signed or not.
787
+ const decryptedSubject = message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security?.subject : undefined;
788
+ const shownSubject = (protectedSubject ?? decryptedSubject ?? displaySubject(message.subject)) || "(no subject)";
789
+ useEffect(() => {
790
+ if (decryptedSubject) {
791
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: decryptedSubject });
792
+ }
793
+ }, [decryptedSubject, message.uid, message.mailboxUid]);
785
794
  // What the body area shows. Text or HTML the client recovered from a signed or encrypted message is shown as it is (its own sanitizing
786
795
  // happens in `MessageBody`, like every body's); anything else is the server's own `/content`. An encrypted message has nothing worth
787
796
  // fetching until it has been decrypted (the server only has the ciphertext), so it shows the skeleton meanwhile.
@@ -807,24 +816,28 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
807
816
  const sendingStatus = (_jsxs(_Fragment, { children: [sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
808
817
  (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }));
809
818
  // ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
810
- /** Prints this one message: its header lines and the body the pane shows (what was decrypted or verified here, else the server's sanitized
811
- * body), in a frame of its own - see `printMessage.ts`. */
819
+ /** Prints this message and the conversation it belongs to: the message first, then every earlier one, newest to oldest (the messages that
820
+ * came after it are left out). Each is the body the reading pane shows - what was decrypted or verified here for this one, else the server's
821
+ * sanitized body - in a frame of its own; see `printMessage.ts`. */
812
822
  async function handlePrint() {
813
823
  try {
814
824
  const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
815
- const format = (list) => list.map((r) => formatMailAddress(r)).join(", ");
816
- const printable = buildPrintDocument({
817
- subject: shownSubject,
818
- headers: [
819
- { name: "From", value: senderLabel },
820
- { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
821
- { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
822
- { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
823
- ],
824
- content,
825
- attachments,
826
- inlineParts: innerAttachments,
827
- });
825
+ const older = await olderConversationMessages(message, client).catch(() => []);
826
+ const earlier = await Promise.all(older.map((other) => printableOlderMessage(other).catch(() => ({
827
+ subject: other.subject || "(no subject)",
828
+ headers: printHeaders(other, formatMailAddress(other.from)),
829
+ content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
830
+ }))));
831
+ const printable = buildConversationPrintDocument(shownSubject, [
832
+ {
833
+ subject: shownSubject,
834
+ headers: printHeaders(message, senderLabel),
835
+ content,
836
+ attachments,
837
+ inlineParts: innerAttachments,
838
+ },
839
+ ...earlier,
840
+ ]);
828
841
  if (printable === undefined) {
829
842
  notify({ kind: "warning", title: "This message is too large to print here" });
830
843
  return;
@@ -11,8 +11,8 @@ export interface InviteRowChipProps {
11
11
  export declare function showsInviteChip(message: Pick<Message, "meetingMethod" | "encrypted">): boolean;
12
12
  /**
13
13
  * The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
14
- * zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
15
- * Accept, Decline and Tentative (`InviteRsvpPopover`).
14
+ * zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
15
+ * Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
16
16
  *
17
17
  * The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
18
18
  * it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useRef, useState } from "react";
7
7
  import { HiOutlineCalendarDays } from "react-icons/hi2";
8
- import { ANSWER_LABEL, formatRowStart } from "./inviteFormat.js";
8
+ import { formatRowStart } from "./inviteFormat.js";
9
9
  import { useMessageInvite } from "./inviteStore.js";
10
10
  import InviteRsvpPopover from "./InviteRsvpPopover.js";
11
11
  /** Whether a list row draws the chip: a meeting request the server can read (an encrypted message's calendar file is inside its ciphertext). */
@@ -14,8 +14,8 @@ export function showsInviteChip(message) {
14
14
  }
15
15
  /**
16
16
  * The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
17
- * zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
18
- * Accept, Decline and Tentative (`InviteRsvpPopover`).
17
+ * zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
18
+ * Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
19
19
  *
20
20
  * The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
21
21
  * it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
@@ -28,10 +28,11 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
28
28
  const { invite, setInvite } = useMessageInvite(message.uid, eligible);
29
29
  const [open, setOpen] = useState(false);
30
30
  const rsvpRef = useRef(null);
31
- if (!eligible) {
31
+ const answer = invite?.response ?? message.meetingResponse;
32
+ // Once the reader has answered, the row has nothing left to ask: the chip goes away (the answer stays on the message itself, and can be changed there).
33
+ if (!eligible || answer) {
32
34
  return null;
33
35
  }
34
- const answer = invite?.response ?? message.meetingResponse;
35
36
  const conflicts = invite?.conflicts.length ?? 0;
36
37
  const start = invite ? formatRowStart(invite) : undefined;
37
38
  function answered(updated) {
@@ -39,8 +40,5 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
39
40
  onResponded?.({ ...message, meetingResponse: updated.response ?? message.meetingResponse });
40
41
  }
41
42
  const stop = (event) => event.stopPropagation();
42
- return (_jsxs("div", { "data-invite-chip": true, onClick: stop, onTouchStart: stop, onTouchMove: stop, onTouchEnd: stop, onTouchCancel: stop, className: [className, "mb-2 -mt-1 flex items-center gap-2 rounded-sm border border-border bg-surface px-2 py-1.5 text-xs font-normal"].join(" "), children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-text", children: start ?? "Meeting request" }), invite && (_jsx("div", { className: [
43
- "truncate",
44
- answer === "accepted" ? "text-success" : answer === "declined" ? "text-danger" : conflicts > 0 ? "text-danger" : "text-text-muted",
45
- ].join(" "), children: answer ? ANSWER_LABEL[answer] : conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts" }))] }), invite?.canRespond && !invite.isOrganizer && (_jsx("button", { ref: rsvpRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `RSVP to ${invite.summary?.trim() || "this meeting"}`, onClick: () => setOpen((wasOpen) => !wasOpen), className: "shrink-0 py-1 px-3 rounded-sm border border-border text-xs font-semibold text-text hover:bg-surface-alt", children: "RSVP" })), open && invite && (_jsx(InviteRsvpPopover, { messageUid: message.uid, invite: invite, anchorRef: rsvpRef, onDone: answered, onClose: () => setOpen(false) }))] }));
43
+ return (_jsxs("div", { "data-invite-chip": true, onClick: stop, onTouchStart: stop, onTouchMove: stop, onTouchEnd: stop, onTouchCancel: stop, className: [className, "mb-2 -mt-1 flex items-center gap-2 rounded-sm border border-border bg-surface px-2 py-1.5 text-xs font-normal"].join(" "), children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-text", children: start ?? "Meeting request" }), invite && (_jsx("div", { className: ["truncate", conflicts > 0 ? "text-danger" : "text-text-muted"].join(" "), children: conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts" }))] }), invite?.canRespond && !invite.isOrganizer && (_jsx("button", { ref: rsvpRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `RSVP to ${invite.summary?.trim() || "this meeting"}`, onClick: () => setOpen((wasOpen) => !wasOpen), className: "shrink-0 py-1 px-3 rounded-sm border border-border text-xs font-semibold text-text hover:bg-surface-alt", children: "RSVP" })), open && invite && (_jsx(InviteRsvpPopover, { messageUid: message.uid, invite: invite, anchorRef: rsvpRef, onDone: answered, onClose: () => setOpen(false) }))] }));
46
44
  }
@@ -11,7 +11,7 @@ export interface InviteRsvpPopoverProps {
11
11
  }
12
12
  /**
13
13
  * What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
14
- * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
14
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
15
15
  * An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
16
16
  * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
17
17
  *
@@ -40,7 +40,7 @@ function estimateHeight(invite, proposing) {
40
40
  const RESPONSES = [
41
41
  { response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
42
42
  { response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
43
- { response: "tentative", label: "Tentative", text: "?", failure: "Couldn't respond tentatively to this meeting" },
43
+ { response: "tentative", label: "Tentative", text: "Maybe", failure: "Couldn't respond tentatively to this meeting" },
44
44
  ];
45
45
  /** What is inside the RSVP popover (or dialog): the meeting, a day view around it and the buttons that answer it. */
46
46
  function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }) {
@@ -117,7 +117,7 @@ function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }) {
117
117
  }
118
118
  /**
119
119
  * What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
120
- * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
120
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
121
121
  * An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
122
122
  * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
123
123
  *
@@ -0,0 +1,15 @@
1
+ import { type Message } from "../../../../../lib/mail/mailApi.js";
2
+ import type { ApiClient } from "../../../../../lib/util/api.js";
3
+ import type { PrintableMessage } from "./printMessage.js";
4
+ /** The header lines printed above a message's body. */
5
+ export declare function printHeaders(message: Message, from: string): PrintableMessage["headers"];
6
+ /**
7
+ * The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
8
+ * the server has no id for (or one that can't be read) has none: the message prints alone.
9
+ */
10
+ export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
11
+ /**
12
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
13
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
14
+ */
15
+ export declare function printableOlderMessage(message: Message): Promise<PrintableMessage>;
@@ -0,0 +1,69 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { listConversationMessages } from "../../../../../lib/mail/conversationsApi.js";
7
+ import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
8
+ import { getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
9
+ import { fetchBodyContent } from "./bodyContent.js";
10
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./EncryptedPreview.js";
11
+ const format = (list) => list.map((r) => formatMailAddress(r)).join(", ");
12
+ /** The header lines printed above a message's body. */
13
+ export function printHeaders(message, from) {
14
+ return [
15
+ { name: "From", value: from },
16
+ { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
17
+ { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
18
+ { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
19
+ ];
20
+ }
21
+ /**
22
+ * The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
23
+ * the server has no id for (or one that can't be read) has none: the message prints alone.
24
+ */
25
+ export async function olderConversationMessages(message, client) {
26
+ if (!message.conversationId) {
27
+ return [];
28
+ }
29
+ const thread = await listConversationMessages(message.mailboxUid, message.conversationId, { limit: 500 }, client);
30
+ const sent = new Date(message.receivedDate).getTime();
31
+ return thread
32
+ .filter((other) => other.uid !== message.uid && new Date(other.receivedDate).getTime() <= sent)
33
+ .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
34
+ }
35
+ /**
36
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
37
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
38
+ */
39
+ export async function printableOlderMessage(message) {
40
+ const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
41
+ if (message.encrypted) {
42
+ const unlocked = getUnlockedKeys(message.mailboxUid);
43
+ if (unlocked) {
44
+ const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
45
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
46
+ const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
47
+ if (content) {
48
+ return {
49
+ subject: (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security.subject : undefined) || displaySubject(message.subject) || "(no subject)",
50
+ headers: printHeaders(message, formatMailAddress(message.from)),
51
+ content,
52
+ attachments,
53
+ inlineParts: security.attachments,
54
+ };
55
+ }
56
+ }
57
+ return {
58
+ subject: displaySubject(message.subject) || "(no subject)",
59
+ headers: printHeaders(message, formatMailAddress(message.from)),
60
+ content: { kind: "text", text: "This message is encrypted and could not be decrypted, so it is not printed." },
61
+ };
62
+ }
63
+ return {
64
+ subject: message.subject || "(no subject)",
65
+ headers: printHeaders(message, formatMailAddress(message.from)),
66
+ content: await fetchBodyContent(message.uid, message.version),
67
+ attachments,
68
+ };
69
+ }
@@ -2,7 +2,7 @@ import type { Attachment } from "../../../../../lib/mail/mailApi.js";
2
2
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
3
3
  import { BodyContent } from "./bodyContent.js";
4
4
  /**
5
- * Printing one message: a document of its own - the message's header lines, and under them the same sanitized body the reading pane draws -
5
+ * Printing a message, and the conversation it belongs to: a document of its own - each message's header lines, and under them the same sanitized body the reading pane draws -
6
6
  * shown in a frame that nobody sees and printed from there, so the print is the message and not the app around it.
7
7
  *
8
8
  * The frame is as isolated as the reading pane's own (`frameDocument.ts`): the body has been through `prepareBodyHtml()`, the document
@@ -26,5 +26,11 @@ export declare function escapeHtml(text: string): string;
26
26
  * The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
27
27
  */
28
28
  export declare function buildPrintDocument(message: PrintableMessage): string | undefined;
29
+ /**
30
+ * The document to print for a conversation: `subject` as the title, then each of `messages` in the order given (the message that was asked for
31
+ * first, then the ones before it), each under its own header lines and set apart from the last. A message whose subject differs from the title
32
+ * says so under its rule. Only the first message is required to be printable: a later one too large to prepare is replaced by a note saying so.
33
+ */
34
+ export declare function buildConversationPrintDocument(subject: string, messages: PrintableMessage[]): string | undefined;
29
35
  /** Prints `html` (from `buildPrintDocument()`) from a frame nobody sees, and removes the frame when the print is over. */
30
36
  export declare function printDocument(html: string): void;
@@ -9,6 +9,8 @@ dl{margin:0 0 12px;display:grid;grid-template-columns:max-content 1fr;gap:2px 12
9
9
  dt{font-weight:600;color:#444}
10
10
  dd{margin:0;overflow-wrap:anywhere}
11
11
  hr{border:0;border-top:1px solid #999;margin:0 0 16px}
12
+ h2{font-size:16px;margin:0 0 12px}
13
+ .rr-older{margin-top:28px;padding-top:20px;border-top:2px solid #000;break-inside:auto}
12
14
  #rr-body{overflow-wrap:break-word}
13
15
  #rr-body img{max-width:100%;height:auto}
14
16
  #rr-body pre,pre.rr-text{white-space:pre-wrap;font:inherit;margin:0}
@@ -16,10 +18,8 @@ hr{border:0;border-top:1px solid #999;margin:0 0 16px}
16
18
  export function escapeHtml(text) {
17
19
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
18
20
  }
19
- /**
20
- * The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
21
- */
22
- export function buildPrintDocument(message) {
21
+ /** One message's header lines and body, or `undefined` for a body too large to prepare (the pane does not draw it either). */
22
+ function renderSection(message) {
23
23
  let body;
24
24
  let imageSource;
25
25
  if (message.content.kind === "html") {
@@ -38,13 +38,40 @@ export function buildPrintDocument(message) {
38
38
  .filter((header) => header.value !== "")
39
39
  .map((header) => `<dt>${escapeHtml(header.name)}</dt><dd>${escapeHtml(header.value)}</dd>`)
40
40
  .join("");
41
+ return { html: `<dl>${lines}</dl><hr>${body}`, imageSource };
42
+ }
43
+ /**
44
+ * The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
45
+ */
46
+ export function buildPrintDocument(message) {
47
+ return buildConversationPrintDocument(message.subject, [message]);
48
+ }
49
+ /**
50
+ * The document to print for a conversation: `subject` as the title, then each of `messages` in the order given (the message that was asked for
51
+ * first, then the ones before it), each under its own header lines and set apart from the last. A message whose subject differs from the title
52
+ * says so under its rule. Only the first message is required to be printable: a later one too large to prepare is replaced by a note saying so.
53
+ */
54
+ export function buildConversationPrintDocument(subject, messages) {
55
+ const sections = [];
56
+ let imageSource;
57
+ for (const [index, message] of messages.entries()) {
58
+ const section = renderSection(message);
59
+ if (section === undefined && index === 0) {
60
+ return undefined;
61
+ }
62
+ imageSource ?? (imageSource = section?.imageSource);
63
+ const html = section?.html ?? `<p>${escapeHtml(`"${message.subject}" is too large to print here.`)}</p>`;
64
+ sections.push(index === 0
65
+ ? html
66
+ : `<div class="rr-older">${message.subject !== subject ? `<h2>${escapeHtml(message.subject)}</h2>` : ""}${html}</div>`);
67
+ }
41
68
  return [
42
69
  "<!doctype html>",
43
70
  '<html lang="en"><head><meta charset="utf-8">',
44
71
  `<meta http-equiv="Content-Security-Policy" content="${frameCsp(imageSource)}">`,
45
- `<title>${escapeHtml(message.subject)}</title>`,
72
+ `<title>${escapeHtml(subject)}</title>`,
46
73
  `<style>${PRINT_CSS}</style></head>`,
47
- `<body><h1>${escapeHtml(message.subject)}</h1><dl>${lines}</dl><hr>${body}</body></html>`,
74
+ `<body><h1>${escapeHtml(subject)}</h1>${sections.join("")}</body></html>`,
48
75
  ].join("");
49
76
  }
50
77
  /** How long a print frame is kept when the browser never says it has finished with it (`afterprint`). */
@@ -107,6 +107,12 @@ export declare const SHORTCUTS: {
107
107
  label: string;
108
108
  scope: "mail";
109
109
  };
110
+ readonly permanentDelete: {
111
+ id: string;
112
+ keys: string[];
113
+ label: string;
114
+ scope: "mail";
115
+ };
110
116
  readonly archive: {
111
117
  id: string;
112
118
  keys: string[];
@@ -38,6 +38,7 @@ export const SHORTCUTS = {
38
38
  replyAll: def({ id: "mail.replyAll", keys: ["mod+shift+r"], label: "Reply all", scope: "mail" }),
39
39
  forward: def({ id: "mail.forward", keys: ["mod+shift+f"], label: "Forward", scope: "mail" }),
40
40
  delete: def({ id: "mail.delete", keys: ["mod+d", "delete"], label: "Delete", scope: "mail" }),
41
+ permanentDelete: def({ id: "mail.permanentDelete", keys: ["shift+delete"], label: "Delete permanently", scope: "mail" }),
41
42
  archive: def({ id: "mail.archive", keys: ["e", "backspace"], label: "Archive", scope: "mail" }),
42
43
  move: def({ id: "mail.move", keys: ["mod+shift+v"], label: "Move to folder", scope: "mail" }),
43
44
  markRead: def({ id: "mail.markRead", keys: ["ctrl+q"], label: "Mark as read", scope: "mail" }),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What this device has recovered of encrypted messages by decrypting them: the real subject (the server only ever holds RFC 9788's `[...]`
3
+ * placeholder) and a short preview. Shared by every view that draws a message - the message list, the conversation list and the reading pane -
4
+ * so a message decrypted in one shows its subject in all of them. Held in memory only, keyed by message uid, and dropped for a mailbox the moment
5
+ * its keys are locked, exactly like the decrypted content it came from.
6
+ */
7
+ export interface DecryptedMessage {
8
+ mailboxUid: string;
9
+ subject?: string;
10
+ preview?: string;
11
+ }
12
+ /** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
13
+ export declare function previewOf(html: string): string;
14
+ /** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
15
+ export declare function rememberDecrypted(uid: string, mailboxUid: string, found: {
16
+ subject?: string;
17
+ preview?: string;
18
+ }): void;
19
+ /** Every message decrypted so far, by uid; a new object whenever any changes. */
20
+ export declare function useDecryptedMessages(): Record<string, DecryptedMessage>;
21
+ /** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
22
+ export declare function useUnlockEpoch(): number;
23
+ /**
24
+ * Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
25
+ * message that can't be fetched or opened is simply left as it was. Never prompts.
26
+ */
27
+ export declare function decryptForDisplay(uid: string, mailboxUid: string): Promise<void>;