@remit/web-client 0.0.1

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 (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,676 @@
1
+ import {
2
+ configOperationsGetConfigOptions,
3
+ outboxDetailOperationsDeleteOutboxMessageMutation,
4
+ outboxDetailOperationsGetOutboxMessageOptions,
5
+ outboxDetailOperationsSendOutboxMessageMutation,
6
+ outboxOperationsCreateOutboxMessageMutation,
7
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import type {
9
+ RemitImapAccountResponse,
10
+ RemitImapDescribeMessageResponse,
11
+ } from "@remit/api-http-client/types.gen.ts";
12
+ import {
13
+ ComposeActionBar,
14
+ ComposeFormShell,
15
+ QuotedText,
16
+ } from "@remit/ui";
17
+ import { useMutation, useQuery } from "@tanstack/react-query";
18
+ import type { Value } from "platejs";
19
+ import {
20
+ lazy,
21
+ Suspense,
22
+ useCallback,
23
+ useEffect,
24
+ useRef,
25
+ useState,
26
+ } from "react";
27
+ import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
28
+ import { useSaveDraft } from "../../hooks/useSaveDraft";
29
+ import { useSignature } from "../../hooks/useSignature.js";
30
+ import {
31
+ plateValueToHtml,
32
+ plateValueToText,
33
+ } from "../../lib/plate-serializer.js";
34
+ import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
35
+ import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
36
+ import {
37
+ buildMutationErrorBanner,
38
+ formatErrorDetail,
39
+ } from "../ui/error-banners.js";
40
+ import type { AddressEntry } from "./AddressField";
41
+ import { AddressField } from "./AddressField";
42
+ import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
43
+
44
+ const LazyComposeBody = lazy(() =>
45
+ import("./ComposeBody.js").then((m) => ({ default: m.ComposeBody })),
46
+ );
47
+
48
+ const ComposeBodyFallback = () => (
49
+ <div className="min-h-[120px] px-3 py-2">
50
+ <div className="h-8 mb-2 rounded bg-surface-sunken animate-pulse" />
51
+ <div className="min-h-[80px] rounded bg-surface-sunken/50 animate-pulse" />
52
+ </div>
53
+ );
54
+
55
+ import { useIsDesktop } from "../../hooks/useMediaQuery.js";
56
+ import { useVisualViewport } from "../../hooks/useVisualViewport.js";
57
+ import type { ComposeMode } from "./ComposeProvider";
58
+ import { useCompose } from "./ComposeProvider";
59
+ import { FromSelector } from "./FromSelector";
60
+ import { SubjectField } from "./SubjectField";
61
+ import { sanitizeQuoteHtml } from "./sanitize-quote-html.js";
62
+
63
+ interface ComposeFormProps {
64
+ mode: ComposeMode;
65
+ account?: RemitImapAccountResponse;
66
+ sourceMessage?: RemitImapDescribeMessageResponse;
67
+ onClose: () => void;
68
+ onAccountChange?: (account: RemitImapAccountResponse) => void;
69
+ }
70
+
71
+ const EMPTY_PARAGRAPH: Value = [{ type: "p", children: [{ text: "" }] }];
72
+
73
+ const SIGNATURE_SEPARATOR: Value = [
74
+ { type: "p", children: [{ text: "" }] },
75
+ { type: "p", children: [{ text: "-- " }] },
76
+ ];
77
+
78
+ const buildInitialBody = (signaturePlainText: string): Value => {
79
+ if (!signaturePlainText) return EMPTY_PARAGRAPH;
80
+ return [
81
+ ...SIGNATURE_SEPARATOR,
82
+ { type: "p", children: [{ text: signaturePlainText }] },
83
+ ];
84
+ };
85
+
86
+ const buildReplySubject = (subject?: string): string => {
87
+ if (!subject) return "Re: ";
88
+ if (/^re:\s/i.test(subject)) return subject;
89
+ return `Re: ${subject}`;
90
+ };
91
+
92
+ const buildForwardSubject = (subject?: string): string => {
93
+ if (!subject) return "Fwd: ";
94
+ if (/^fwd?:\s/i.test(subject)) return subject;
95
+ return `Fwd: ${subject}`;
96
+ };
97
+
98
+ const getReplyAddresses = (
99
+ msg: RemitImapDescribeMessageResponse,
100
+ mode: ComposeMode,
101
+ myEmail?: string,
102
+ ): { to: AddressEntry[]; cc: AddressEntry[] } => {
103
+ const { envelope } = msg;
104
+ const replyTo =
105
+ envelope.replyTo.length > 0 ? envelope.replyTo : envelope.from;
106
+
107
+ const to: AddressEntry[] = replyTo.map((a) => ({
108
+ email: a.normalizedEmail,
109
+ displayName: a.displayName,
110
+ }));
111
+
112
+ if (mode !== "reply_all") return { to, cc: [] };
113
+
114
+ const myEmailLower = myEmail?.toLowerCase();
115
+ const toEmails = new Set(to.map((a) => a.email.toLowerCase()));
116
+
117
+ const cc: AddressEntry[] = [...envelope.to, ...envelope.cc]
118
+ .filter(
119
+ (a) =>
120
+ a.normalizedEmail.toLowerCase() !== myEmailLower &&
121
+ !toEmails.has(a.normalizedEmail.toLowerCase()),
122
+ )
123
+ .map((a) => ({
124
+ email: a.normalizedEmail,
125
+ displayName: a.displayName,
126
+ }));
127
+
128
+ return { to, cc };
129
+ };
130
+
131
+ const getReferences = (
132
+ msg: RemitImapDescribeMessageResponse,
133
+ ): { inReplyTo?: string; references: string[] } => {
134
+ const messageIdValue = msg.envelope.messageIdValue;
135
+ const existingRefs = msg.references
136
+ .filter((r) => r.referenceType === "references")
137
+ .sort((a, b) => a.referenceOrder - b.referenceOrder)
138
+ .map((r) => r.messageIdValue);
139
+
140
+ const references = [...existingRefs];
141
+ if (messageIdValue && !references.includes(messageIdValue)) {
142
+ references.push(messageIdValue);
143
+ }
144
+
145
+ return {
146
+ inReplyTo: messageIdValue,
147
+ references,
148
+ };
149
+ };
150
+
151
+ const isFormEmpty = (
152
+ toAddresses: AddressEntry[],
153
+ ccAddresses: AddressEntry[],
154
+ bccAddresses: AddressEntry[],
155
+ subject: string,
156
+ body: Value,
157
+ ): boolean =>
158
+ toAddresses.length === 0 &&
159
+ ccAddresses.length === 0 &&
160
+ bccAddresses.length === 0 &&
161
+ subject.trim() === "" &&
162
+ plateValueToText(body).trim() === "";
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // ComposeHeader — collapsed on mobile when the software keyboard is open
166
+ // ---------------------------------------------------------------------------
167
+
168
+ interface ComposeHeaderProps {
169
+ selectedAccountId?: string;
170
+ onAccountChange: (account: RemitImapAccountResponse) => void;
171
+ toAddresses: AddressEntry[];
172
+ setToAddresses: (v: AddressEntry[]) => void;
173
+ ccAddresses: AddressEntry[];
174
+ setCcAddresses: (v: AddressEntry[]) => void;
175
+ bccAddresses: AddressEntry[];
176
+ setBccAddresses: (v: AddressEntry[]) => void;
177
+ showCc: boolean;
178
+ setShowCc: (v: boolean) => void;
179
+ showBcc: boolean;
180
+ setShowBcc: (v: boolean) => void;
181
+ subject: string;
182
+ setSubject: (v: string) => void;
183
+ }
184
+
185
+ const ComposeHeader = ({
186
+ selectedAccountId,
187
+ onAccountChange,
188
+ toAddresses,
189
+ setToAddresses,
190
+ ccAddresses,
191
+ setCcAddresses,
192
+ bccAddresses,
193
+ setBccAddresses,
194
+ showCc,
195
+ setShowCc,
196
+ showBcc,
197
+ setShowBcc,
198
+ subject,
199
+ setSubject,
200
+ }: ComposeHeaderProps) => {
201
+ const isDesktop = useIsDesktop();
202
+ const { isKeyboardOpen } = useVisualViewport();
203
+ const collapsed = !isDesktop && isKeyboardOpen;
204
+
205
+ if (collapsed) {
206
+ // Compact single-line summary when the keyboard eats vertical space
207
+ const chips: string[] = [];
208
+ if (toAddresses.length > 0)
209
+ chips.push(
210
+ `To: ${toAddresses.map((a) => a.displayName ?? a.email).join(", ")}`,
211
+ );
212
+ if (ccAddresses.length > 0) chips.push(`Cc: ${ccAddresses.length}`);
213
+ if (bccAddresses.length > 0) chips.push(`Bcc: ${bccAddresses.length}`);
214
+ if (subject) chips.push(subject);
215
+
216
+ return (
217
+ <div
218
+ className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
219
+ data-testid="compose-header-collapsed"
220
+ >
221
+ <span className="truncate text-xs text-fg-muted">
222
+ {chips.length > 0 ? chips.join(" · ") : "…"}
223
+ </span>
224
+ <span className="shrink-0 inline-flex items-center justify-center rounded bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-muted">
225
+
226
+ </span>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ return (
232
+ <div className="space-y-1 px-3 py-2 border-b border-line">
233
+ <FromSelector
234
+ selectedAccountId={selectedAccountId}
235
+ onSelect={onAccountChange}
236
+ />
237
+ <AddressField
238
+ label="To"
239
+ addresses={toAddresses}
240
+ onChange={setToAddresses}
241
+ placeholder="Recipients"
242
+ />
243
+ {showCc ? (
244
+ <AddressField
245
+ label="Cc"
246
+ addresses={ccAddresses}
247
+ onChange={setCcAddresses}
248
+ />
249
+ ) : (
250
+ <div className="flex gap-2 pl-14">
251
+ <button
252
+ type="button"
253
+ onClick={() => setShowCc(true)}
254
+ className="text-xs text-fg-muted hover:text-fg transition-colors"
255
+ >
256
+ Cc
257
+ </button>
258
+ <button
259
+ type="button"
260
+ onClick={() => setShowBcc(true)}
261
+ className="text-xs text-fg-muted hover:text-fg transition-colors"
262
+ >
263
+ Bcc
264
+ </button>
265
+ </div>
266
+ )}
267
+ {showCc && !showBcc && (
268
+ <div className="pl-14">
269
+ <button
270
+ type="button"
271
+ onClick={() => setShowBcc(true)}
272
+ className="text-xs text-fg-muted hover:text-fg transition-colors"
273
+ >
274
+ Bcc
275
+ </button>
276
+ </div>
277
+ )}
278
+ {showBcc && (
279
+ <AddressField
280
+ label="Bcc"
281
+ addresses={bccAddresses}
282
+ onChange={setBccAddresses}
283
+ />
284
+ )}
285
+ <SubjectField value={subject} onChange={setSubject} />
286
+ </div>
287
+ );
288
+ };
289
+
290
+ export const ComposeForm = ({
291
+ mode,
292
+ account,
293
+ sourceMessage,
294
+ onClose,
295
+ onAccountChange,
296
+ }: ComposeFormProps) => {
297
+ const { state, setOutboxMessageId, startSendPolling } = useCompose();
298
+ const { pushError } = useErrorBanners();
299
+ const { outboxMessageId } = state;
300
+
301
+ const [toAddresses, setToAddresses] = useState<AddressEntry[]>([]);
302
+ const [ccAddresses, setCcAddresses] = useState<AddressEntry[]>([]);
303
+ const [bccAddresses, setBccAddresses] = useState<AddressEntry[]>([]);
304
+ const [subject, setSubject] = useState("");
305
+ const [showCc, setShowCc] = useState(false);
306
+ const [showBcc, setShowBcc] = useState(false);
307
+ const [selectedAccountId, setSelectedAccountId] = useState(
308
+ account?.accountId,
309
+ );
310
+ const [draftLoaded, setDraftLoaded] = useState(false);
311
+ const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
312
+
313
+ // Reset form when the user switches to a different draft (outboxMessageId
314
+ // changes while compose is already open). Without this, the previous draft's
315
+ // fields stay visible and the new draft never loads because draftLoaded
316
+ // remains true from the prior session (#536).
317
+ useEffect(() => {
318
+ if (prevOutboxMessageIdRef.current === outboxMessageId) return;
319
+ prevOutboxMessageIdRef.current = outboxMessageId;
320
+ if (!outboxMessageId) return;
321
+ setToAddresses([]);
322
+ setCcAddresses([]);
323
+ setBccAddresses([]);
324
+ setSubject("");
325
+ setShowCc(false);
326
+ setShowBcc(false);
327
+ setBody(EMPTY_PARAGRAPH);
328
+ setDraftLoaded(false);
329
+ }, [outboxMessageId]);
330
+
331
+ const { signature } = useSignature(selectedAccountId);
332
+ const [body, setBody] = useState<Value>(() =>
333
+ buildInitialBody(signature.plainText),
334
+ );
335
+
336
+ const { data: draftData } = useQuery({
337
+ ...outboxDetailOperationsGetOutboxMessageOptions({
338
+ path: { outboxMessageId: outboxMessageId ?? "" },
339
+ }),
340
+ enabled: !!outboxMessageId && !draftLoaded,
341
+ });
342
+
343
+ useEffect(() => {
344
+ if (!draftData || draftLoaded) return;
345
+
346
+ setToAddresses(
347
+ draftData.toAddresses.map((email) => ({ email, displayName: undefined })),
348
+ );
349
+ if (draftData.ccAddresses && draftData.ccAddresses.length > 0) {
350
+ setCcAddresses(
351
+ draftData.ccAddresses.map((email) => ({
352
+ email,
353
+ displayName: undefined,
354
+ })),
355
+ );
356
+ setShowCc(true);
357
+ }
358
+ if (draftData.bccAddresses && draftData.bccAddresses.length > 0) {
359
+ setBccAddresses(
360
+ draftData.bccAddresses.map((email) => ({
361
+ email,
362
+ displayName: undefined,
363
+ })),
364
+ );
365
+ setShowBcc(true);
366
+ }
367
+ if (draftData.subject) setSubject(draftData.subject);
368
+ if (draftData.textBody)
369
+ setBody([{ type: "p", children: [{ text: draftData.textBody }] }]);
370
+ setSelectedAccountId(draftData.accountId);
371
+ setDraftLoaded(true);
372
+ }, [draftData, draftLoaded]);
373
+
374
+ useEffect(() => {
375
+ if (!sourceMessage) return;
376
+
377
+ if (mode === "reply" || mode === "reply_all") {
378
+ const { to, cc } = getReplyAddresses(sourceMessage, mode, account?.email);
379
+ setToAddresses(to);
380
+ setCcAddresses(cc);
381
+ if (cc.length > 0) setShowCc(true);
382
+ setSubject(buildReplySubject(sourceMessage.envelope.subject));
383
+ }
384
+
385
+ if (mode === "forward") {
386
+ setSubject(buildForwardSubject(sourceMessage.envelope.subject));
387
+ }
388
+ }, [mode, sourceMessage, account?.email]);
389
+
390
+ // Quoted reply/forward content lives at the per-part `contentUrl` since
391
+ // #224 PR 3 — fetch it via the same hook MessageBody uses, and degrade
392
+ // to an empty quote when nothing renderable exists (the user can still
393
+ // attribute the reply manually).
394
+ const isQuoting =
395
+ mode === "reply" || mode === "reply_all" || mode === "forward";
396
+ const { data: sourceBody } = useMessageBodyContent({
397
+ messageId: sourceMessage?.message.messageId,
398
+ bodyParts: sourceMessage?.bodyParts,
399
+ enabled: isQuoting && !!sourceMessage,
400
+ });
401
+
402
+ const quotedText = sourceBody?.kind === "text" ? sourceBody.body : "";
403
+ const quotedHtml =
404
+ sourceBody?.kind === "html"
405
+ ? sanitizeQuoteHtml(sourceBody.body)
406
+ : undefined;
407
+
408
+ const senderName =
409
+ sourceMessage?.envelope.from[0]?.displayName ??
410
+ sourceMessage?.envelope.from[0]?.normalizedEmail;
411
+
412
+ const { saveStatus, saveError, saveDraft, cancelAutoSave } = useSaveDraft({
413
+ outboxMessageId,
414
+ onDraftCreated: setOutboxMessageId,
415
+ });
416
+
417
+ // Auto-save runs on a debounce, so a failure has no inline call site to
418
+ // surface it. Push the real error detail to a banner instead of leaving only
419
+ // the muted "Save failed" status dot. A fatal 5xx also hits the global
420
+ // escalation overlay via MutationCache.onError.
421
+ useEffect(() => {
422
+ if (!saveError) return;
423
+ pushError({
424
+ title: "Couldn't save draft",
425
+ detail: formatErrorDetail(saveError) ?? "Saving the draft failed.",
426
+ });
427
+ }, [saveError, pushError]);
428
+
429
+ const createMutation = useMutation(
430
+ outboxOperationsCreateOutboxMessageMutation(),
431
+ );
432
+
433
+ const sendMutation = useMutation(
434
+ outboxDetailOperationsSendOutboxMessageMutation(),
435
+ );
436
+
437
+ const deleteMutation = useMutation({
438
+ ...outboxDetailOperationsDeleteOutboxMessageMutation(),
439
+ onError: (error) => {
440
+ // Discard closes the dialog optimistically. A soft 4xx (409/404 the
441
+ // draft is already gone) must not pass silently as success — surface a
442
+ // banner. A fatal 5xx still escalates through MutationCache.onError.
443
+ pushError(
444
+ buildMutationErrorBanner(
445
+ "Couldn't discard draft",
446
+ "The draft wasn't discarded.",
447
+ error,
448
+ ),
449
+ );
450
+ },
451
+ });
452
+
453
+ const { data: config } = useQuery({
454
+ ...configOperationsGetConfigOptions(),
455
+ staleTime: Infinity,
456
+ });
457
+
458
+ const selectedAccount = config?.accounts.find(
459
+ (a) => a.accountId === selectedAccountId,
460
+ );
461
+ const selectedAccountMissingSmtp = selectedAccount
462
+ ? accountIsMissingSmtp(selectedAccount)
463
+ : false;
464
+
465
+ const isSending = createMutation.isPending || sendMutation.isPending;
466
+ const canSend =
467
+ toAddresses.length > 0 &&
468
+ !!selectedAccountId &&
469
+ !selectedAccountMissingSmtp;
470
+
471
+ useEffect(() => {
472
+ if (!selectedAccountId) return;
473
+ // Don't autosave while a draft is still being loaded. The fields are mid-
474
+ // population when opening an existing draft, or have just been blanked for
475
+ // a draft switch (#535/#536). Saving now would either write the previous
476
+ // draft's content to the new outboxMessageId or create a spurious duplicate.
477
+ if (outboxMessageId && !draftLoaded) return;
478
+ if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
479
+ return;
480
+
481
+ const textBody = plateValueToText(body);
482
+ const htmlBody = plateValueToHtml(body);
483
+
484
+ saveDraft({
485
+ accountId: selectedAccountId,
486
+ toAddresses: toAddresses.map((a) => a.email),
487
+ ccAddresses:
488
+ ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
489
+ bccAddresses:
490
+ bccAddresses.length > 0 ? bccAddresses.map((a) => a.email) : undefined,
491
+ subject: subject || undefined,
492
+ textBody: textBody || undefined,
493
+ htmlBody: htmlBody || undefined,
494
+ });
495
+ }, [
496
+ selectedAccountId,
497
+ outboxMessageId,
498
+ draftLoaded,
499
+ toAddresses,
500
+ ccAddresses,
501
+ bccAddresses,
502
+ subject,
503
+ body,
504
+ saveDraft,
505
+ ]);
506
+
507
+ const handleSend = useCallback(async () => {
508
+ if (!selectedAccountId || toAddresses.length === 0) return;
509
+
510
+ cancelAutoSave();
511
+
512
+ const replyData =
513
+ sourceMessage && (mode === "reply" || mode === "reply_all")
514
+ ? getReferences(sourceMessage)
515
+ : {};
516
+
517
+ let messageId = outboxMessageId;
518
+ let createdThisAttempt = false;
519
+
520
+ if (!messageId) {
521
+ const textBody = plateValueToText(body);
522
+ const htmlBody = plateValueToHtml(body);
523
+
524
+ const outboxMessage = await createMutation
525
+ .mutateAsync({
526
+ body: {
527
+ accountId: selectedAccountId,
528
+ toAddresses: toAddresses.map((a) => a.email),
529
+ ccAddresses:
530
+ ccAddresses.length > 0
531
+ ? ccAddresses.map((a) => a.email)
532
+ : undefined,
533
+ bccAddresses:
534
+ bccAddresses.length > 0
535
+ ? bccAddresses.map((a) => a.email)
536
+ : undefined,
537
+ subject: subject || undefined,
538
+ textBody: textBody || undefined,
539
+ htmlBody: htmlBody || undefined,
540
+ sendImmediately: true,
541
+ ...replyData,
542
+ },
543
+ })
544
+ .catch((error: unknown) => {
545
+ pushError({
546
+ title: "Couldn't send message",
547
+ detail: formatErrorDetail(error) ?? "Saving the draft failed.",
548
+ });
549
+ return null;
550
+ });
551
+ if (outboxMessage === null) return;
552
+ messageId = outboxMessage.outboxMessageId;
553
+ createdThisAttempt = true;
554
+ setOutboxMessageId(messageId);
555
+ }
556
+
557
+ const sent = await sendMutation
558
+ .mutateAsync({
559
+ path: { outboxMessageId: messageId },
560
+ })
561
+ .catch((error: unknown) => {
562
+ pushError({
563
+ title: "Couldn't send message",
564
+ detail:
565
+ formatErrorDetail(error) ??
566
+ (createdThisAttempt
567
+ ? "The draft was saved but the send request failed. Try again from the Outbox."
568
+ : "The send request failed. Try again."),
569
+ });
570
+ return null;
571
+ });
572
+ if (sent === null) return;
573
+
574
+ startSendPolling(messageId);
575
+ onClose();
576
+ }, [
577
+ selectedAccountId,
578
+ toAddresses,
579
+ ccAddresses,
580
+ bccAddresses,
581
+ subject,
582
+ body,
583
+ mode,
584
+ sourceMessage,
585
+ outboxMessageId,
586
+ createMutation,
587
+ sendMutation,
588
+ cancelAutoSave,
589
+ startSendPolling,
590
+ setOutboxMessageId,
591
+ pushError,
592
+ onClose,
593
+ ]);
594
+
595
+ const handleDiscard = useCallback(() => {
596
+ cancelAutoSave();
597
+ if (outboxMessageId) {
598
+ deleteMutation.mutate({
599
+ path: { outboxMessageId },
600
+ });
601
+ }
602
+ onClose();
603
+ }, [cancelAutoSave, outboxMessageId, deleteMutation, onClose]);
604
+
605
+ const handleAccountChange = useCallback(
606
+ (acct: RemitImapAccountResponse) => {
607
+ setSelectedAccountId(acct.accountId);
608
+ onAccountChange?.(acct);
609
+ },
610
+ [onAccountChange],
611
+ );
612
+
613
+ return (
614
+ <ComposeFormShell
615
+ banner={
616
+ selectedAccount && selectedAccountMissingSmtp ? (
617
+ <ComposeSmtpMissingBanner accountId={selectedAccount.accountId} />
618
+ ) : undefined
619
+ }
620
+ header={
621
+ <ComposeHeader
622
+ selectedAccountId={selectedAccountId}
623
+ onAccountChange={handleAccountChange}
624
+ toAddresses={toAddresses}
625
+ setToAddresses={setToAddresses}
626
+ ccAddresses={ccAddresses}
627
+ setCcAddresses={setCcAddresses}
628
+ bccAddresses={bccAddresses}
629
+ setBccAddresses={setBccAddresses}
630
+ showCc={showCc}
631
+ setShowCc={setShowCc}
632
+ showBcc={showBcc}
633
+ setShowBcc={setShowBcc}
634
+ subject={subject}
635
+ setSubject={setSubject}
636
+ />
637
+ }
638
+ quoted={
639
+ quotedText || quotedHtml ? (
640
+ <QuotedText
641
+ text={quotedText}
642
+ html={quotedHtml}
643
+ senderName={senderName}
644
+ />
645
+ ) : undefined
646
+ }
647
+ actionBar={
648
+ <ComposeActionBar
649
+ onSend={handleSend}
650
+ onDiscard={handleDiscard}
651
+ sending={isSending}
652
+ canSend={canSend}
653
+ saveStatus={saveStatus}
654
+ unavailableReason={
655
+ selectedAccountMissingSmtp ? "SMTP not configured" : undefined
656
+ }
657
+ onUnavailable={(reason) =>
658
+ pushError({
659
+ title: "Can't send yet",
660
+ detail: reason,
661
+ })
662
+ }
663
+ />
664
+ }
665
+ >
666
+ <Suspense fallback={<ComposeBodyFallback />}>
667
+ <LazyComposeBody
668
+ value={body}
669
+ onChange={setBody}
670
+ onSubmit={handleSend}
671
+ autoFocus={mode === "new"}
672
+ />
673
+ </Suspense>
674
+ </ComposeFormShell>
675
+ );
676
+ };