@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
@@ -39,8 +39,8 @@ export const SETUP_STEPS = [
39
39
  { id: "domain", title: "Domain", intro: "Add the domain this server receives mail for, then publish the DNS records below." },
40
40
  { id: "settings", title: "Server settings", intro: "Set the rules for encryption, how long mail is kept, and new mailboxes." },
41
41
  { id: "escrow", title: "Escrow", intro: "Decide whether encrypted mail can be recovered by a trusted group of holders." },
42
- { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
43
42
  { id: "mailboxes", title: "Mailboxes", intro: "Create the first mailboxes, starting with your own." },
43
+ { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
44
44
  ] as const;
45
45
 
46
46
  export type SetupStepId = (typeof SETUP_STEPS)[number]["id"];
@@ -73,7 +73,9 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
73
73
  <div className="flex flex-1">
74
74
  <div className="w-14 shrink-0">
75
75
  {HOURS.map((hour) => (
76
- <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
76
+ // Each label sits on its hour line, raised by half its own height with `relative`/`-top-1.5`: a negative margin here would
77
+ // also shorten the row, and the labels would drift up a little more with every hour (48px lines, 42px labels).
78
+ <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="relative -top-1.5 text-[10px] text-text-muted text-right pr-1.5">
77
79
  {hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
78
80
  </div>
79
81
  ))}
@@ -2,13 +2,14 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useState } from "react";
5
+ import React, { useEffect, useState } from "react";
6
6
  import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
8
  import { Message } from "../../../../lib/mail/mailApi.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 { ConversationSummary, 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";
@@ -98,6 +99,18 @@ export default function ConversationList({
98
99
  newestFirst,
99
100
  }: ConversationListProps) {
100
101
  const client = useApiClient();
102
+ // What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
103
+ const decrypted = useDecryptedMessages();
104
+ const unlockEpoch = useUnlockEpoch();
105
+ // An encrypted conversation's row stands for its latest message, whose subject only decrypting can tell. Rows are decrypted as they are listed
106
+ // whenever the mailbox is unlocked (and again once it is), the same way the message list does.
107
+ useEffect(() => {
108
+ for (const conversation of conversations) {
109
+ if (conversationLooksEncrypted(conversation)) {
110
+ void decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
111
+ }
112
+ }
113
+ }, [conversations, mailboxUid, unlockEpoch]);
101
114
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
102
115
  const [messagesById, setMessagesById] = useState<Record<string, Message[]>>({});
103
116
  const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
@@ -226,10 +239,10 @@ export default function ConversationList({
226
239
  {new Date(conversation.latestDate).toLocaleDateString()}
227
240
  </span>
228
241
  </div>
229
- <div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{conversation.subject || "(no subject)"}</div>
242
+ <div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)"}</div>
230
243
  <div className="text-xs text-text-muted truncate font-normal">
231
244
  {/* An encrypted latest message has no preview (the server never had its plaintext): say so, with a lock, not nothing. */}
232
- {conversationLooksEncrypted(conversation) ? <EncryptedPreview /> : conversation.latestPreview}
245
+ {conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || <EncryptedPreview /> : conversation.latestPreview}
233
246
  </div>
234
247
  <div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
235
248
  {conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
@@ -301,7 +314,7 @@ export default function ConversationList({
301
314
  </span>
302
315
  </div>
303
316
  <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
304
- <span className="truncate">{message.bodyPreview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
317
+ <span className="truncate">{message.bodyPreview || decrypted[message.uid]?.preview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
305
318
  {message.hasAttachments && (
306
319
  <HiOutlinePaperClip size={12} aria-label="Has attachments" />
307
320
  )}
@@ -11,7 +11,8 @@ import { Label } from "../../../../lib/mail/labelsApi.js";
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";
@@ -192,6 +193,7 @@ export default function ConversationThreadPane({
192
193
  const client = useApiClient();
193
194
  // 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.
194
195
  const outgoing = useOutgoingReplies();
196
+ const decryptedMessages = useDecryptedMessages();
195
197
  const [messages, setMessages] = useState<Message[]>([]);
196
198
  const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
197
199
  const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
@@ -495,7 +497,10 @@ export default function ConversationThreadPane({
495
497
  if (!conversation) {
496
498
  return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
497
499
  }
498
- const subject = displaySubject(conversation.subject) || "(no subject)";
500
+ // An encrypted thread's subject is the placeholder until one of its messages has been decrypted here; the newest one that has says it.
501
+ const decryptedSubject =
502
+ conversation.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? messages.map((message) => decryptedMessages[message.uid]?.subject).find(Boolean) : undefined;
503
+ const subject = (decryptedSubject ?? displaySubject(conversation.subject)) || "(no subject)";
499
504
  // The newest message that is open carries the Reply / Forward buttons at the foot of its card.
500
505
  const footerUid = messages.find((message) => expandedUids.has(message.uid))?.uid;
501
506
 
@@ -80,14 +80,16 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
80
80
  import LabelMenuButton from "./labelMenu.js";
81
81
  import Button from "../../../../lib/components/buttons/Button.js";
82
82
  import EncryptedBody from "./reading/EncryptedBody.js";
83
- import { displaySubject } from "./reading/EncryptedPreview.js";
83
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
84
+ import { rememberDecrypted } from "../../mail/decryptedMessages.js";
84
85
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
85
86
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
86
87
  import MessageMoreMenu, { MessageMenuActions } from "./reading/MessageMoreMenu.js";
87
88
  import MessageSourceDialog, { SourceMode, SourceState } from "./reading/MessageSourceDialog.js";
88
89
  import { useMessageActions } from "./reading/useMessageActions.js";
89
90
  import { saveAsEml } from "./reading/messageExport.js";
90
- import { buildPrintDocument, printDocument } from "./reading/printMessage.js";
91
+ import { buildConversationPrintDocument, printDocument, type PrintableMessage } from "./reading/printMessage.js";
92
+ import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
91
93
  import { type BodyContent, fetchBodyContent } from "./reading/bodyContent.js";
92
94
  import { useViewOriginal } from "./reading/viewOriginal.js";
93
95
  import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
@@ -1042,7 +1044,14 @@ function MessageDetailContent({
1042
1044
  ? senderKeyState!.conflict
1043
1045
  : undefined;
1044
1046
 
1045
- const shownSubject = (protectedSubject ?? displaySubject(message.subject)) || "(no subject)";
1047
+ // An encrypted message's outer Subject is only RFC 9788's placeholder: whatever decrypting recovered is the subject, signed or not.
1048
+ const decryptedSubject = message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security?.subject : undefined;
1049
+ const shownSubject = (protectedSubject ?? decryptedSubject ?? displaySubject(message.subject)) || "(no subject)";
1050
+ useEffect(() => {
1051
+ if (decryptedSubject) {
1052
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: decryptedSubject });
1053
+ }
1054
+ }, [decryptedSubject, message.uid, message.mailboxUid]);
1046
1055
  // 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
1047
1056
  // happens in `MessageBody`, like every body's); anything else is the server's own `/content`. An encrypted message has nothing worth
1048
1057
  // fetching until it has been decrypted (the server only has the ciphertext), so it shows the skeleton meanwhile.
@@ -1104,24 +1113,34 @@ function MessageDetailContent({
1104
1113
 
1105
1114
  // ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
1106
1115
 
1107
- /** Prints this one message: its header lines and the body the pane shows (what was decrypted or verified here, else the server's sanitized
1108
- * body), in a frame of its own - see `printMessage.ts`. */
1116
+ /** Prints this message and the conversation it belongs to: the message first, then every earlier one, newest to oldest (the messages that
1117
+ * 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
1118
+ * sanitized body - in a frame of its own; see `printMessage.ts`. */
1109
1119
  async function handlePrint() {
1110
1120
  try {
1111
1121
  const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
1112
- const format = (list: Recipient[]) => list.map((r) => formatMailAddress(r)).join(", ");
1113
- const printable = buildPrintDocument({
1114
- subject: shownSubject,
1115
- headers: [
1116
- { name: "From", value: senderLabel },
1117
- { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
1118
- { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
1119
- { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
1120
- ],
1121
- content,
1122
- attachments,
1123
- inlineParts: innerAttachments,
1124
- });
1122
+ const older = await olderConversationMessages(message, client).catch(() => []);
1123
+ const earlier = await Promise.all(
1124
+ older.map((other) =>
1125
+ printableOlderMessage(other).catch(
1126
+ (): PrintableMessage => ({
1127
+ subject: other.subject || "(no subject)",
1128
+ headers: printHeaders(other, formatMailAddress(other.from)),
1129
+ content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
1130
+ }),
1131
+ ),
1132
+ ),
1133
+ );
1134
+ const printable = buildConversationPrintDocument(shownSubject, [
1135
+ {
1136
+ subject: shownSubject,
1137
+ headers: printHeaders(message, senderLabel),
1138
+ content,
1139
+ attachments,
1140
+ inlineParts: innerAttachments,
1141
+ },
1142
+ ...earlier,
1143
+ ]);
1125
1144
  if (printable === undefined) {
1126
1145
  notify({ kind: "warning", title: "This message is too large to print here" });
1127
1146
  return;
@@ -6,7 +6,7 @@ import React, { useRef, useState } from "react";
6
6
  import { HiOutlineCalendarDays } from "react-icons/hi2";
7
7
  import type { Message } from "../../../../../lib/mail/mailApi.js";
8
8
  import type { MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
9
- import { ANSWER_LABEL, formatRowStart } from "./inviteFormat.js";
9
+ import { formatRowStart } from "./inviteFormat.js";
10
10
  import { useMessageInvite } from "./inviteStore.js";
11
11
  import InviteRsvpPopover from "./InviteRsvpPopover.js";
12
12
 
@@ -25,8 +25,8 @@ export function showsInviteChip(message: Pick<Message, "meetingMethod" | "encryp
25
25
 
26
26
  /**
27
27
  * 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
28
- * 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
29
- * Accept, Decline and Tentative (`InviteRsvpPopover`).
28
+ * zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
29
+ * Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
30
30
  *
31
31
  * 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
32
32
  * it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
@@ -39,11 +39,12 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
39
39
  const { invite, setInvite } = useMessageInvite(message.uid, eligible);
40
40
  const [open, setOpen] = useState(false);
41
41
  const rsvpRef = useRef<HTMLButtonElement>(null);
42
- if (!eligible) {
42
+ const answer = invite?.response ?? message.meetingResponse;
43
+ // 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).
44
+ if (!eligible || answer) {
43
45
  return null;
44
46
  }
45
47
 
46
- const answer = invite?.response ?? message.meetingResponse;
47
48
  const conflicts = invite?.conflicts.length ?? 0;
48
49
  const start = invite ? formatRowStart(invite) : undefined;
49
50
 
@@ -67,13 +68,8 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
67
68
  <div className="min-w-0 flex-1">
68
69
  <div className="truncate text-text">{start ?? "Meeting request"}</div>
69
70
  {invite && (
70
- <div
71
- className={[
72
- "truncate",
73
- answer === "accepted" ? "text-success" : answer === "declined" ? "text-danger" : conflicts > 0 ? "text-danger" : "text-text-muted",
74
- ].join(" ")}
75
- >
76
- {answer ? ANSWER_LABEL[answer] : conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts"}
71
+ <div className={["truncate", conflicts > 0 ? "text-danger" : "text-text-muted"].join(" ")}>
72
+ {conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts"}
77
73
  </div>
78
74
  )}
79
75
  </div>
@@ -42,7 +42,7 @@ function estimateHeight(invite: MessageInvite, proposing: boolean): number {
42
42
  const RESPONSES: { response: InviteResponse; label: string; text: string; failure: string }[] = [
43
43
  { response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
44
44
  { response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
45
- { response: "tentative", label: "Tentative", text: "?", failure: "Couldn't respond tentatively to this meeting" },
45
+ { response: "tentative", label: "Tentative", text: "Maybe", failure: "Couldn't respond tentatively to this meeting" },
46
46
  ];
47
47
 
48
48
  interface RsvpPanelProps {
@@ -236,7 +236,7 @@ export interface InviteRsvpPopoverProps {
236
236
 
237
237
  /**
238
238
  * 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
239
- * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
239
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
240
240
  * 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
241
241
  * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
242
242
  *
@@ -0,0 +1,76 @@
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, type Message, type Recipient } from "../../../../../lib/mail/mailApi.js";
9
+ import type { ApiClient } from "../../../../../lib/util/api.js";
10
+ import { fetchBodyContent } from "./bodyContent.js";
11
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./EncryptedPreview.js";
12
+ import type { PrintableMessage } from "./printMessage.js";
13
+
14
+ const format = (list: Recipient[]) => list.map((r) => formatMailAddress(r)).join(", ");
15
+
16
+ /** The header lines printed above a message's body. */
17
+ export function printHeaders(message: Message, from: string): PrintableMessage["headers"] {
18
+ return [
19
+ { name: "From", value: from },
20
+ { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
21
+ { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
22
+ { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
23
+ ];
24
+ }
25
+
26
+ /**
27
+ * The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
28
+ * the server has no id for (or one that can't be read) has none: the message prints alone.
29
+ */
30
+ export async function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]> {
31
+ if (!message.conversationId) {
32
+ return [];
33
+ }
34
+ const thread = await listConversationMessages(message.mailboxUid, message.conversationId, { limit: 500 }, client);
35
+ const sent = new Date(message.receivedDate).getTime();
36
+ return thread
37
+ .filter((other) => other.uid !== message.uid && new Date(other.receivedDate).getTime() <= sent)
38
+ .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
39
+ }
40
+
41
+ /**
42
+ * 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
43
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
44
+ */
45
+ export async function printableOlderMessage(message: Message): Promise<PrintableMessage> {
46
+ const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
47
+ if (message.encrypted) {
48
+ const unlocked = getUnlockedKeys(message.mailboxUid);
49
+ if (unlocked) {
50
+ const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
51
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
52
+ const content =
53
+ security.text !== undefined ? ({ kind: "text", text: security.text } as const) : security.html !== undefined ? ({ kind: "html", html: security.html } as const) : undefined;
54
+ if (content) {
55
+ return {
56
+ subject: (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security.subject : undefined) || displaySubject(message.subject) || "(no subject)",
57
+ headers: printHeaders(message, formatMailAddress(message.from)),
58
+ content,
59
+ attachments,
60
+ inlineParts: security.attachments,
61
+ };
62
+ }
63
+ }
64
+ return {
65
+ subject: displaySubject(message.subject) || "(no subject)",
66
+ headers: printHeaders(message, formatMailAddress(message.from)),
67
+ content: { kind: "text", text: "This message is encrypted and could not be decrypted, so it is not printed." },
68
+ };
69
+ }
70
+ return {
71
+ subject: message.subject || "(no subject)",
72
+ headers: printHeaders(message, formatMailAddress(message.from)),
73
+ content: await fetchBodyContent(message.uid, message.version),
74
+ attachments,
75
+ };
76
+ }
@@ -9,7 +9,7 @@ import { prepareBodyHtml } from "./bodyHtml.js";
9
9
  import { frameCsp } from "./frameDocument.js";
10
10
 
11
11
  /**
12
- * Printing one message: a document of its own - the message's header lines, and under them the same sanitized body the reading pane draws -
12
+ * 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 -
13
13
  * shown in a frame that nobody sees and printed from there, so the print is the message and not the app around it.
14
14
  *
15
15
  * The frame is as isolated as the reading pane's own (`frameDocument.ts`): the body has been through `prepareBodyHtml()`, the document
@@ -35,6 +35,8 @@ dl{margin:0 0 12px;display:grid;grid-template-columns:max-content 1fr;gap:2px 12
35
35
  dt{font-weight:600;color:#444}
36
36
  dd{margin:0;overflow-wrap:anywhere}
37
37
  hr{border:0;border-top:1px solid #999;margin:0 0 16px}
38
+ h2{font-size:16px;margin:0 0 12px}
39
+ .rr-older{margin-top:28px;padding-top:20px;border-top:2px solid #000;break-inside:auto}
38
40
  #rr-body{overflow-wrap:break-word}
39
41
  #rr-body img{max-width:100%;height:auto}
40
42
  #rr-body pre,pre.rr-text{white-space:pre-wrap;font:inherit;margin:0}
@@ -44,10 +46,14 @@ export function escapeHtml(text: string): string {
44
46
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
45
47
  }
46
48
 
47
- /**
48
- * The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
49
- */
50
- export function buildPrintDocument(message: PrintableMessage): string | undefined {
49
+ interface PrintSection {
50
+ /** The `<dl>`, rule and body of one message. */
51
+ html: string;
52
+ imageSource?: string;
53
+ }
54
+
55
+ /** One message's header lines and body, or `undefined` for a body too large to prepare (the pane does not draw it either). */
56
+ function renderSection(message: PrintableMessage): PrintSection | undefined {
51
57
  let body: string;
52
58
  let imageSource: string | undefined;
53
59
  if (message.content.kind === "html") {
@@ -65,13 +71,44 @@ export function buildPrintDocument(message: PrintableMessage): string | undefine
65
71
  .filter((header) => header.value !== "")
66
72
  .map((header) => `<dt>${escapeHtml(header.name)}</dt><dd>${escapeHtml(header.value)}</dd>`)
67
73
  .join("");
74
+ return { html: `<dl>${lines}</dl><hr>${body}`, imageSource };
75
+ }
76
+
77
+ /**
78
+ * The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
79
+ */
80
+ export function buildPrintDocument(message: PrintableMessage): string | undefined {
81
+ return buildConversationPrintDocument(message.subject, [message]);
82
+ }
83
+
84
+ /**
85
+ * 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
86
+ * 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
87
+ * 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.
88
+ */
89
+ export function buildConversationPrintDocument(subject: string, messages: PrintableMessage[]): string | undefined {
90
+ const sections: string[] = [];
91
+ let imageSource: string | undefined;
92
+ for (const [index, message] of messages.entries()) {
93
+ const section = renderSection(message);
94
+ if (section === undefined && index === 0) {
95
+ return undefined;
96
+ }
97
+ imageSource ??= section?.imageSource;
98
+ const html = section?.html ?? `<p>${escapeHtml(`"${message.subject}" is too large to print here.`)}</p>`;
99
+ sections.push(
100
+ index === 0
101
+ ? html
102
+ : `<div class="rr-older">${message.subject !== subject ? `<h2>${escapeHtml(message.subject)}</h2>` : ""}${html}</div>`,
103
+ );
104
+ }
68
105
  return [
69
106
  "<!doctype html>",
70
107
  '<html lang="en"><head><meta charset="utf-8">',
71
108
  `<meta http-equiv="Content-Security-Policy" content="${frameCsp(imageSource)}">`,
72
- `<title>${escapeHtml(message.subject)}</title>`,
109
+ `<title>${escapeHtml(subject)}</title>`,
73
110
  `<style>${PRINT_CSS}</style></head>`,
74
- `<body><h1>${escapeHtml(message.subject)}</h1><dl>${lines}</dl><hr>${body}</body></html>`,
111
+ `<body><h1>${escapeHtml(subject)}</h1>${sections.join("")}</body></html>`,
75
112
  ].join("");
76
113
  }
77
114
 
@@ -66,6 +66,7 @@ export const SHORTCUTS = {
66
66
  replyAll: def({ id: "mail.replyAll", keys: ["mod+shift+r"], label: "Reply all", scope: "mail" }),
67
67
  forward: def({ id: "mail.forward", keys: ["mod+shift+f"], label: "Forward", scope: "mail" }),
68
68
  delete: def({ id: "mail.delete", keys: ["mod+d", "delete"], label: "Delete", scope: "mail" }),
69
+ permanentDelete: def({ id: "mail.permanentDelete", keys: ["shift+delete"], label: "Delete permanently", scope: "mail" }),
69
70
  archive: def({ id: "mail.archive", keys: ["e", "backspace"], label: "Archive", scope: "mail" }),
70
71
  move: def({ id: "mail.move", keys: ["mod+shift+v"], label: "Move to folder", scope: "mail" }),
71
72
  markRead: def({ id: "mail.markRead", keys: ["ctrl+q"], label: "Mark as read", scope: "mail" }),
@@ -0,0 +1,116 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useSyncExternalStore } from "react";
6
+ import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
7
+ import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
8
+
9
+ /**
10
+ * What this device has recovered of encrypted messages by decrypting them: the real subject (the server only ever holds RFC 9788's `[...]`
11
+ * placeholder) and a short preview. Shared by every view that draws a message - the message list, the conversation list and the reading pane -
12
+ * 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
13
+ * its keys are locked, exactly like the decrypted content it came from.
14
+ */
15
+ export interface DecryptedMessage {
16
+ mailboxUid: string;
17
+ subject?: string;
18
+ preview?: string;
19
+ }
20
+
21
+ const PREVIEW_MAX_LENGTH = 160;
22
+
23
+ let entries: Record<string, DecryptedMessage> = {};
24
+ /** Bumped whenever a mailbox's keys are unlocked, so a view can retry decrypting what it couldn't before. */
25
+ let unlockEpoch = 0;
26
+ const listeners = new Set<() => void>();
27
+ const inFlight = new Set<string>();
28
+
29
+ function emit(): void {
30
+ for (const listener of [...listeners]) {
31
+ listener();
32
+ }
33
+ }
34
+
35
+ subscribeKeySession(({ mailboxUid, state }) => {
36
+ if (state === "locked") {
37
+ const kept = Object.fromEntries(Object.entries(entries).filter(([, entry]) => entry.mailboxUid !== mailboxUid));
38
+ if (Object.keys(kept).length !== Object.keys(entries).length) {
39
+ entries = kept;
40
+ }
41
+ } else {
42
+ unlockEpoch++;
43
+ }
44
+ emit();
45
+ });
46
+
47
+ function subscribe(listener: () => void): () => void {
48
+ listeners.add(listener);
49
+ return () => {
50
+ listeners.delete(listener);
51
+ };
52
+ }
53
+
54
+ /** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
55
+ export function previewOf(html: string): string {
56
+ return html
57
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
58
+ .replace(/<[^>]+>/g, " ")
59
+ .replace(/&nbsp;/gi, " ")
60
+ .replace(/&amp;/gi, "&")
61
+ .replace(/&lt;/gi, "<")
62
+ .replace(/&gt;/gi, ">")
63
+ .replace(/&quot;/gi, '"')
64
+ .replace(/&#0*39;/gi, "'")
65
+ .replace(/\s+/g, " ")
66
+ .trim()
67
+ .slice(0, PREVIEW_MAX_LENGTH);
68
+ }
69
+
70
+ /** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
71
+ export function rememberDecrypted(uid: string, mailboxUid: string, found: { subject?: string; preview?: string }): void {
72
+ if (!found.subject && !found.preview) {
73
+ return;
74
+ }
75
+ const current = entries[uid];
76
+ if (current && current.subject === found.subject && current.preview === found.preview) {
77
+ return;
78
+ }
79
+ entries = { ...entries, [uid]: { mailboxUid, subject: found.subject, preview: found.preview } };
80
+ emit();
81
+ }
82
+
83
+ /** Every message decrypted so far, by uid; a new object whenever any changes. */
84
+ export function useDecryptedMessages(): Record<string, DecryptedMessage> {
85
+ return useSyncExternalStore(subscribe, () => entries);
86
+ }
87
+
88
+ /** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
89
+ export function useUnlockEpoch(): number {
90
+ return useSyncExternalStore(subscribe, () => unlockEpoch);
91
+ }
92
+
93
+ /**
94
+ * 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
95
+ * message that can't be fetched or opened is simply left as it was. Never prompts.
96
+ */
97
+ export async function decryptForDisplay(uid: string, mailboxUid: string): Promise<void> {
98
+ const unlocked = getUnlockedKeys(mailboxUid);
99
+ if (!unlocked || entries[uid] || inFlight.has(uid)) {
100
+ return;
101
+ }
102
+ inFlight.add(uid);
103
+ try {
104
+ const rawMime = await getMessageRawContent(uid);
105
+ const { evaluateMessageSecurity } = await import("../../../lib/crypto/messageSecurity.js");
106
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
107
+ // A lock while this ran: the result must not go back on screen.
108
+ if (getUnlockedKeys(mailboxUid) === unlocked) {
109
+ rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
110
+ }
111
+ } catch {
112
+ // Left as the placeholder.
113
+ } finally {
114
+ inFlight.delete(uid);
115
+ }
116
+ }
@@ -69,6 +69,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
69
69
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
70
70
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
71
71
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
72
+ import { rememberDecrypted } from "../shared/mail/decryptedMessages.js";
72
73
  import ConversationList, { ListedConversation, conversationRowKey } from "../shared/components/mail/ConversationList.js";
73
74
  import type { ConversationThreadHead } from "../shared/components/mail/ConversationThreadPane.js";
74
75
  import SwipeRow from "../shared/components/mail/SwipeRow.js";
@@ -159,6 +160,7 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
159
160
  return null;
160
161
  }
161
162
  const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
163
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: security.subject, preview });
162
164
  return [message.uid, { subject: security.subject, preview }];
163
165
  } catch {
164
166
  return null;
@@ -2497,11 +2499,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
2497
2499
  );
2498
2500
  }
2499
2501
 
2502
+ /** Shift+Delete, from the keyboard: permanently deletes the targets wherever they are, after the same confirmation as Delete in Deleted Items. */
2503
+ async function permanentDeleteFromKeyboard() {
2504
+ const targets = await keyboardTargets();
2505
+ if (targets.length > 0 && (await purgeChosen(targets)) && threadPane && !selectMode) {
2506
+ closeOpenThread();
2507
+ }
2508
+ }
2509
+
2500
2510
  useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
2501
2511
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
2502
2512
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
2503
2513
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
2504
2514
  useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2515
+ useShortcut(SHORTCUTS.mail.permanentDelete, () => void permanentDeleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2505
2516
  useShortcut(
2506
2517
  SHORTCUTS.mail.markRead,
2507
2518
  () =>
@@ -15,14 +15,14 @@ export declare const SETUP_STEPS: readonly [{
15
15
  readonly id: "escrow";
16
16
  readonly title: "Escrow";
17
17
  readonly intro: "Decide whether encrypted mail can be recovered by a trusted group of holders.";
18
- }, {
19
- readonly id: "branding";
20
- readonly title: "Branding";
21
- readonly intro: "Make the sign-in page and apps look like your organization.";
22
18
  }, {
23
19
  readonly id: "mailboxes";
24
20
  readonly title: "Mailboxes";
25
21
  readonly intro: "Create the first mailboxes, starting with your own.";
22
+ }, {
23
+ readonly id: "branding";
24
+ readonly title: "Branding";
25
+ readonly intro: "Make the sign-in page and apps look like your organization.";
26
26
  }];
27
27
  export type SetupStepId = (typeof SETUP_STEPS)[number]["id"];
28
28
  export interface SetupWizardProps {
@@ -36,8 +36,8 @@ export const SETUP_STEPS = [
36
36
  { id: "domain", title: "Domain", intro: "Add the domain this server receives mail for, then publish the DNS records below." },
37
37
  { id: "settings", title: "Server settings", intro: "Set the rules for encryption, how long mail is kept, and new mailboxes." },
38
38
  { id: "escrow", title: "Escrow", intro: "Decide whether encrypted mail can be recovered by a trusted group of holders." },
39
- { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
40
39
  { id: "mailboxes", title: "Mailboxes", intro: "Create the first mailboxes, starting with your own." },
40
+ { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
41
41
  ];
42
42
  function isStepId(value) {
43
43
  return SETUP_STEPS.some((step) => step.id === value);