@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,942 @@
1
+ import { zodResolver } from "@hookform/resolvers/zod";
2
+ import {
3
+ accountDetailOperationsUpdateAccountMutation,
4
+ accountOperationsCreateAccountMutation,
5
+ accountOperationsTestConnectionMutation,
6
+ configOperationsGetConfigQueryKey,
7
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import { Button, Input, Select, securityToApi } from "@remit/ui";
10
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
11
+ import { Check, Loader2, X } from "lucide-react";
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { useForm } from "react-hook-form";
14
+ import { z } from "zod";
15
+ import { useSignature } from "../../hooks/useSignature";
16
+ import {
17
+ getPresetById,
18
+ PROVIDER_PRESETS,
19
+ type ProviderPreset,
20
+ } from "../../lib/provider-presets.js";
21
+ import { cn } from "../../lib/utils";
22
+ import { SlidePanel } from "../ui/SlidePanel";
23
+ import {
24
+ appendAppPasswordHint,
25
+ computeSmtpAutoFill,
26
+ deriveSmtpHostFromImap,
27
+ } from "./account-form-helpers.js";
28
+
29
+ /**
30
+ * True when the account authenticates via Microsoft OAuth — not a
31
+ * password-based IMAP account. Used to hide irrelevant credential /
32
+ * server fields and show a read-only summary instead.
33
+ *
34
+ * Exported for unit tests.
35
+ */
36
+ export const isOAuthMicrosoftAccount = (
37
+ account: Pick<RemitImapAccountResponse, "authType">,
38
+ ): boolean => account.authType === "oauthMicrosoft";
39
+
40
+ // Placeholder shown when editing - indicates password exists but isn't shown
41
+ const PASSWORD_PLACEHOLDER = "••••••••••";
42
+
43
+ /**
44
+ * Derive the initial state of the "use different SMTP credentials" checkbox
45
+ * from a persisted account. Returns true when the account stores an SMTP
46
+ * username distinct from the IMAP username (i.e. the user previously opted
47
+ * in to separate SMTP credentials).
48
+ *
49
+ * The API response intentionally omits the SMTP password, so `smtpUsername`
50
+ * is the only available signal.
51
+ */
52
+ export const deriveUseDifferentSmtpCreds = (
53
+ account: Pick<RemitImapAccountResponse, "username"> & {
54
+ smtpUsername?: string;
55
+ },
56
+ ): boolean => {
57
+ const smtpUsername = account.smtpUsername?.trim();
58
+ if (!smtpUsername) return false;
59
+ return smtpUsername !== account.username;
60
+ };
61
+
62
+ const accountFormSchema = z.object({
63
+ email: z.string().email().min(1, "Email is required"),
64
+ displayName: z.string().optional(),
65
+ username: z.string().optional(),
66
+ password: z.string().optional(),
67
+ imapHost: z.string().min(1, "IMAP host is required"),
68
+ imapPort: z.coerce.number().int().min(1).max(65535),
69
+ imapTls: z.boolean(),
70
+ imapStartTls: z.boolean(),
71
+ smtpHost: z.string().optional(),
72
+ smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
73
+ smtpTls: z.boolean().optional(),
74
+ smtpStartTls: z.boolean().optional(),
75
+ smtpUsername: z.string().optional(),
76
+ smtpPassword: z.string().optional(),
77
+ useDifferentSmtpCreds: z.boolean().optional(),
78
+ });
79
+
80
+ type AccountFormData = z.infer<typeof accountFormSchema>;
81
+
82
+ interface AccountFormPanelProps {
83
+ isOpen: boolean;
84
+ onClose: () => void;
85
+ account?: RemitImapAccountResponse;
86
+ focusSmtp?: boolean;
87
+ }
88
+
89
+ export const AccountFormPanel = ({
90
+ isOpen,
91
+ onClose,
92
+ account,
93
+ focusSmtp,
94
+ }: AccountFormPanelProps) => {
95
+ const queryClient = useQueryClient();
96
+ const isEditing = !!account;
97
+
98
+ // Track if user has modified the password field
99
+ const [passwordModified, setPasswordModified] = useState(false);
100
+
101
+ // Selected provider preset (empty string = "Custom / other", manual entry)
102
+ const [presetId, setPresetId] = useState("");
103
+ // When a preset is selected its server fields are read-only until the
104
+ // user opts into manual editing via the Advanced toggle.
105
+ const [advancedOpen, setAdvancedOpen] = useState(false);
106
+ const selectedPreset: ProviderPreset | undefined = getPresetById(presetId);
107
+ const serversLocked = !!selectedPreset && !advancedOpen;
108
+
109
+ const smtpSectionRef = useRef<HTMLElement | null>(null);
110
+
111
+ const form = useForm<AccountFormData>({
112
+ resolver: zodResolver(accountFormSchema),
113
+ defaultValues: {
114
+ email: "",
115
+ displayName: "",
116
+ username: "",
117
+ password: "",
118
+ imapHost: "",
119
+ imapPort: 993,
120
+ imapTls: true,
121
+ imapStartTls: false,
122
+ smtpHost: "",
123
+ smtpPort: 587,
124
+ smtpTls: false,
125
+ smtpStartTls: true,
126
+ useDifferentSmtpCreds: false,
127
+ },
128
+ });
129
+
130
+ // Populate form when editing an existing account
131
+ useEffect(() => {
132
+ if (account) {
133
+ const useDifferentSmtpCreds = deriveUseDifferentSmtpCreds(account);
134
+ form.reset({
135
+ email: account.email,
136
+ displayName: account.displayName || "",
137
+ username: account.username || "",
138
+ password: PASSWORD_PLACEHOLDER, // Show placeholder to indicate password exists
139
+ imapHost: account.imapHost,
140
+ imapPort: account.imapPort,
141
+ imapTls: account.imapTls,
142
+ imapStartTls: account.imapStartTls,
143
+ smtpHost: account.smtpHost || "",
144
+ smtpPort: account.smtpPort || 587,
145
+ smtpTls: account.smtpTls || false,
146
+ smtpStartTls: account.smtpStartTls || true,
147
+ smtpUsername: useDifferentSmtpCreds ? account.smtpUsername || "" : "",
148
+ smtpPassword: "",
149
+ useDifferentSmtpCreds,
150
+ });
151
+ setPasswordModified(false);
152
+ } else {
153
+ form.reset({
154
+ email: "",
155
+ displayName: "",
156
+ username: "",
157
+ password: "",
158
+ imapHost: "",
159
+ imapPort: 993,
160
+ imapTls: true,
161
+ imapStartTls: false,
162
+ smtpHost: "",
163
+ smtpPort: 587,
164
+ smtpTls: false,
165
+ smtpStartTls: true,
166
+ useDifferentSmtpCreds: false,
167
+ });
168
+ setPasswordModified(false);
169
+ }
170
+ }, [account, form]);
171
+
172
+ const testMutation = useMutation({
173
+ ...accountOperationsTestConnectionMutation(),
174
+ });
175
+
176
+ const createMutation = useMutation({
177
+ ...accountOperationsCreateAccountMutation(),
178
+ onSuccess: () => {
179
+ queryClient.invalidateQueries({
180
+ queryKey: configOperationsGetConfigQueryKey(),
181
+ });
182
+ onClose();
183
+ },
184
+ });
185
+
186
+ const updateMutation = useMutation({
187
+ ...accountDetailOperationsUpdateAccountMutation(),
188
+ onSuccess: () => {
189
+ queryClient.invalidateQueries({
190
+ queryKey: configOperationsGetConfigQueryKey(),
191
+ });
192
+ onClose();
193
+ },
194
+ });
195
+
196
+ const handleTestImap = () => {
197
+ const values = form.getValues();
198
+ // If editing and password not modified, send accountId so server uses stored password
199
+ const useStoredPassword = isEditing && !passwordModified;
200
+ testMutation.mutate({
201
+ body: {
202
+ accountId: useStoredPassword ? account?.accountId : undefined,
203
+ username: values.username || values.email,
204
+ password: useStoredPassword ? undefined : values.password || undefined,
205
+ imapHost: values.imapHost,
206
+ imapPort: Number(values.imapPort),
207
+ imapTls: values.imapTls,
208
+ imapStartTls: values.imapStartTls,
209
+ },
210
+ });
211
+ };
212
+
213
+ const handleTestSmtp = () => {
214
+ const values = form.getValues();
215
+ // If editing and password not modified, send accountId so server uses stored password
216
+ const useStoredPassword = isEditing && !passwordModified;
217
+ testMutation.mutate({
218
+ body: {
219
+ accountId: useStoredPassword ? account?.accountId : undefined,
220
+ username: values.username || values.email,
221
+ password: useStoredPassword ? undefined : values.password || undefined,
222
+ imapHost: values.imapHost,
223
+ imapPort: Number(values.imapPort),
224
+ imapTls: values.imapTls,
225
+ imapStartTls: values.imapStartTls,
226
+ smtpHost: values.smtpHost || undefined,
227
+ smtpPort: values.smtpPort ? Number(values.smtpPort) : undefined,
228
+ smtpTls: values.smtpTls,
229
+ smtpStartTls: values.smtpStartTls,
230
+ smtpUsername: values.useDifferentSmtpCreds
231
+ ? values.smtpUsername
232
+ : undefined,
233
+ smtpPassword: values.useDifferentSmtpCreds
234
+ ? values.smtpPassword
235
+ : undefined,
236
+ },
237
+ });
238
+ };
239
+
240
+ const handleSubmit = form.handleSubmit((values) => {
241
+ // Validate password for new accounts
242
+ if (!isEditing && !values.password) {
243
+ form.setError("password", { message: "Password is required" });
244
+ return;
245
+ }
246
+
247
+ // Safety net: when the user filled IMAP but left SMTP blank,
248
+ // derive sensible SMTP defaults from the IMAP host so the account
249
+ // can actually send mail. Only fills blanks — never overrides
250
+ // values the user typed. See issue #196.
251
+ const autoFill = computeSmtpAutoFill({
252
+ imapHost: values.imapHost,
253
+ smtpHost: values.smtpHost,
254
+ smtpPort: values.smtpPort,
255
+ smtpTls: values.smtpTls,
256
+ smtpStartTls: values.smtpStartTls,
257
+ });
258
+ if (autoFill) {
259
+ form.setValue("smtpHost", autoFill.smtpHost);
260
+ form.setValue("smtpPort", autoFill.smtpPort);
261
+ form.setValue("smtpTls", autoFill.smtpTls);
262
+ form.setValue("smtpStartTls", autoFill.smtpStartTls);
263
+ }
264
+
265
+ const effectiveSmtpHost = autoFill?.smtpHost ?? values.smtpHost;
266
+ const effectiveSmtpPort = autoFill?.smtpPort ?? values.smtpPort;
267
+ const effectiveSmtpTls = autoFill?.smtpTls ?? values.smtpTls;
268
+ const effectiveSmtpStartTls = autoFill?.smtpStartTls ?? values.smtpStartTls;
269
+
270
+ const baseBody = {
271
+ email: values.email,
272
+ displayName: values.displayName?.trim() || undefined,
273
+ username: values.username || undefined,
274
+ imapHost: values.imapHost,
275
+ imapPort: Number(values.imapPort),
276
+ imapTls: values.imapTls,
277
+ imapStartTls: values.imapStartTls,
278
+ smtpHost: effectiveSmtpHost || undefined,
279
+ smtpPort: effectiveSmtpPort ? Number(effectiveSmtpPort) : undefined,
280
+ smtpTls: effectiveSmtpTls,
281
+ smtpStartTls: effectiveSmtpStartTls,
282
+ smtpUsername: values.useDifferentSmtpCreds
283
+ ? values.smtpUsername || undefined
284
+ : undefined,
285
+ smtpPassword: values.useDifferentSmtpCreds
286
+ ? values.smtpPassword || undefined
287
+ : undefined,
288
+ };
289
+
290
+ if (isEditing && account) {
291
+ const body = {
292
+ ...baseBody,
293
+ // Only send password if it was modified
294
+ password: passwordModified ? values.password : undefined,
295
+ };
296
+ updateMutation.mutate({ path: { accountId: account.accountId }, body });
297
+ } else {
298
+ // Password is validated above for new accounts
299
+ const body = {
300
+ ...baseBody,
301
+ password: values.password as string,
302
+ };
303
+ createMutation.mutate({ body });
304
+ }
305
+ });
306
+
307
+ const isSaving = createMutation.isPending || updateMutation.isPending;
308
+
309
+ const {
310
+ signature,
311
+ setSignature,
312
+ isSaving: isSignatureSaving,
313
+ } = useSignature(account?.accountId);
314
+ const [signatureText, setSignatureText] = useState(signature.plainText);
315
+
316
+ useEffect(() => {
317
+ setSignatureText(signature.plainText);
318
+ }, [signature.plainText]);
319
+
320
+ useEffect(() => {
321
+ if (!isOpen || !focusSmtp) return;
322
+ const el = smtpSectionRef.current;
323
+ if (!el) return;
324
+ const id = window.setTimeout(() => {
325
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
326
+ }, 250);
327
+ return () => window.clearTimeout(id);
328
+ }, [isOpen, focusSmtp]);
329
+
330
+ const handleSignatureBlur = useCallback(() => {
331
+ if (signatureText !== signature.plainText) {
332
+ setSignature(signatureText, signatureText);
333
+ }
334
+ }, [signatureText, signature.plainText, setSignature]);
335
+
336
+ const handleSecurityChange = (type: "tls" | "starttls" | "none") => {
337
+ const { tls, startTls } = securityToApi(type);
338
+ form.setValue("imapTls", tls);
339
+ form.setValue("imapStartTls", startTls);
340
+
341
+ const currentPort = form.getValues("imapPort");
342
+ const isDefaultPort = currentPort === 993 || currentPort === 143;
343
+ if (isDefaultPort) form.setValue("imapPort", type === "tls" ? 993 : 143);
344
+ };
345
+
346
+ const handleSmtpSecurityChange = (type: "tls" | "starttls" | "none") => {
347
+ const { tls, startTls } = securityToApi(type);
348
+ form.setValue("smtpTls", tls);
349
+ form.setValue("smtpStartTls", startTls);
350
+
351
+ const currentPort = form.getValues("smtpPort");
352
+ const isDefaultPort =
353
+ currentPort === 465 || currentPort === 587 || currentPort === 25;
354
+ if (isDefaultPort) {
355
+ const port = type === "tls" ? 465 : type === "starttls" ? 587 : 25;
356
+ form.setValue("smtpPort", port);
357
+ }
358
+ };
359
+
360
+ const handlePrefillFromImap = () => {
361
+ const imapHost = form.getValues("imapHost");
362
+ form.setValue("smtpHost", deriveSmtpHostFromImap(imapHost));
363
+ form.setValue("smtpTls", false);
364
+ form.setValue("smtpStartTls", true);
365
+ form.setValue("smtpPort", 587);
366
+ };
367
+
368
+ const handlePresetChange = (id: string) => {
369
+ setPresetId(id);
370
+ setAdvancedOpen(false);
371
+ const preset = getPresetById(id);
372
+ if (!preset) return;
373
+
374
+ const imapSecurity = securityToApi(preset.imap.security);
375
+ form.setValue("imapHost", preset.imap.host);
376
+ form.setValue("imapPort", preset.imap.port);
377
+ form.setValue("imapTls", imapSecurity.tls);
378
+ form.setValue("imapStartTls", imapSecurity.startTls);
379
+
380
+ const smtpSecurity = securityToApi(preset.smtp.security);
381
+ form.setValue("smtpHost", preset.smtp.host);
382
+ form.setValue("smtpPort", preset.smtp.port);
383
+ form.setValue("smtpTls", smtpSecurity.tls);
384
+ form.setValue("smtpStartTls", smtpSecurity.startTls);
385
+
386
+ const email = form.getValues("email").trim();
387
+ const username = form.getValues("username")?.trim();
388
+ if (email && !username) form.setValue("username", email);
389
+ };
390
+
391
+ const presetHint = selectedPreset?.passwordHelp.text;
392
+ const imapError = appendAppPasswordHint(
393
+ testMutation.data?.imapError,
394
+ presetHint,
395
+ );
396
+ const smtpError = appendAppPasswordHint(
397
+ testMutation.data?.smtpError,
398
+ presetHint,
399
+ );
400
+
401
+ const isOAuthAccount = account ? isOAuthMicrosoftAccount(account) : false;
402
+
403
+ // OAuth Microsoft accounts: show a read-only summary panel instead of
404
+ // the credential / server configuration form.
405
+ if (isOAuthAccount && isEditing) {
406
+ return (
407
+ <SlidePanel
408
+ isOpen={isOpen}
409
+ onClose={onClose}
410
+ title="Edit Account"
411
+ footer={
412
+ <Button type="button" variant="secondary" onClick={onClose}>
413
+ Close
414
+ </Button>
415
+ }
416
+ >
417
+ <div className="space-y-6">
418
+ <section>
419
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
420
+ Microsoft 365 Account
421
+ </h3>
422
+ <div className="space-y-3">
423
+ <div className="rounded-md border border-line bg-surface-sunken px-3 py-2.5 text-sm text-fg-muted">
424
+ <span className="block text-2xs font-medium uppercase tracking-wider text-fg-subtle mb-1">
425
+ Email
426
+ </span>
427
+ <span className="text-fg">{account?.email}</span>
428
+ </div>
429
+ <div className="rounded-md border border-line bg-surface-sunken px-3 py-2.5 text-sm text-fg-muted">
430
+ <span className="block text-2xs font-medium uppercase tracking-wider text-fg-subtle mb-1">
431
+ Authentication
432
+ </span>
433
+ <span className="text-fg">Microsoft OAuth (XOAUTH2)</span>
434
+ </div>
435
+ <p className="text-xs text-fg-muted">
436
+ Server settings are managed automatically for Microsoft 365
437
+ accounts. To update credentials, use the Reconnect button.
438
+ </p>
439
+ </div>
440
+ </section>
441
+ {isEditing && (
442
+ <section>
443
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
444
+ Signature
445
+ </h3>
446
+ <div className="space-y-3">
447
+ <div>
448
+ <label
449
+ htmlFor="email-sig-view"
450
+ className="text-sm font-medium mb-1.5 block"
451
+ >
452
+ Email Signature
453
+ </label>
454
+ <textarea
455
+ id="email-sig-view"
456
+ value={signatureText}
457
+ onChange={(e) => setSignatureText(e.target.value)}
458
+ onBlur={handleSignatureBlur}
459
+ rows={5}
460
+ className="w-full px-3 py-2 border border-line rounded-md bg-surface-sunken text-sm text-fg placeholder:text-fg-subtle focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30 transition-colors resize-y outline-none"
461
+ placeholder="Enter your email signature..."
462
+ />
463
+ <p className="text-xs text-fg-muted mt-1">
464
+ {isSignatureSaving
465
+ ? "Saving signature..."
466
+ : "This signature will be appended to new emails."}
467
+ </p>
468
+ </div>
469
+ </div>
470
+ </section>
471
+ )}
472
+ </div>
473
+ </SlidePanel>
474
+ );
475
+ }
476
+
477
+ return (
478
+ <SlidePanel
479
+ isOpen={isOpen}
480
+ onClose={onClose}
481
+ title={isEditing ? "Edit Account" : "Add Account"}
482
+ footer={
483
+ <>
484
+ <Button type="button" variant="secondary" onClick={onClose}>
485
+ Cancel
486
+ </Button>
487
+ <Button
488
+ type="submit"
489
+ form="account-form"
490
+ variant="primary"
491
+ disabled={isSaving}
492
+ >
493
+ {isSaving ? "Saving..." : "Save Account"}
494
+ </Button>
495
+ </>
496
+ }
497
+ >
498
+ <form id="account-form" onSubmit={handleSubmit} className="space-y-6">
499
+ {/* Account Information Section */}
500
+ <section>
501
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
502
+ Account Information
503
+ </h3>
504
+ <div className="space-y-3">
505
+ <div>
506
+ <label
507
+ htmlFor="account-provider"
508
+ className="text-sm font-medium mb-1.5 block"
509
+ >
510
+ Provider
511
+ </label>
512
+ <Select
513
+ id="account-provider"
514
+ value={presetId}
515
+ onChange={(e) => handlePresetChange(e.target.value)}
516
+ >
517
+ <option value="">Custom / other</option>
518
+ {PROVIDER_PRESETS.map((preset) => (
519
+ <option key={preset.id} value={preset.id}>
520
+ {preset.label}
521
+ </option>
522
+ ))}
523
+ </Select>
524
+ <p className="text-xs text-fg-muted mt-1">
525
+ Pick your email provider to fill in server settings
526
+ automatically, or choose Custom to enter them by hand.
527
+ </p>
528
+ </div>
529
+ <div>
530
+ <label
531
+ htmlFor="account-email"
532
+ className="text-sm font-medium mb-1.5 block"
533
+ >
534
+ Email Address
535
+ </label>
536
+ <Input
537
+ id="account-email"
538
+ {...form.register("email")}
539
+ placeholder="alice@example.com"
540
+ />
541
+ {form.formState.errors.email && (
542
+ <p className="text-sm text-danger mt-1">
543
+ {form.formState.errors.email.message}
544
+ </p>
545
+ )}
546
+ </div>
547
+ <div>
548
+ <label
549
+ htmlFor="account-displayName"
550
+ className="text-sm font-medium mb-1.5 block"
551
+ >
552
+ Display name (optional)
553
+ </label>
554
+ <Input
555
+ id="account-displayName"
556
+ {...form.register("displayName")}
557
+ placeholder="Alice"
558
+ />
559
+ <p className="text-xs text-fg-muted mt-1">
560
+ What to call this account in Remit. Leave blank to use a name
561
+ derived from the address.
562
+ </p>
563
+ </div>
564
+ <div>
565
+ <label
566
+ htmlFor="account-username"
567
+ className="text-sm font-medium mb-1.5 block"
568
+ >
569
+ Username
570
+ </label>
571
+ <Input
572
+ id="account-username"
573
+ {...form.register("username")}
574
+ placeholder="Same as email if empty"
575
+ />
576
+ </div>
577
+ <div>
578
+ <label
579
+ htmlFor="account-password"
580
+ className="text-sm font-medium mb-1.5 block"
581
+ >
582
+ Password
583
+ </label>
584
+ <Input
585
+ id="account-password"
586
+ {...form.register("password", {
587
+ onChange: () => setPasswordModified(true),
588
+ })}
589
+ type="password"
590
+ onFocus={() => {
591
+ // Clear placeholder when user focuses the field
592
+ if (isEditing && !passwordModified) {
593
+ form.setValue("password", "");
594
+ setPasswordModified(true);
595
+ }
596
+ }}
597
+ placeholder={isEditing ? "Leave empty to keep current" : ""}
598
+ />
599
+ {form.formState.errors.password && (
600
+ <p className="text-sm text-danger mt-1">
601
+ {form.formState.errors.password.message}
602
+ </p>
603
+ )}
604
+ {selectedPreset && (
605
+ <p className="text-xs text-fg-muted mt-1">
606
+ {selectedPreset.passwordHelp.text}{" "}
607
+ <a
608
+ href={selectedPreset.passwordHelp.url}
609
+ target="_blank"
610
+ rel="noopener noreferrer"
611
+ className="text-accent hover:underline"
612
+ >
613
+ Get an app password
614
+ </a>
615
+ </p>
616
+ )}
617
+ </div>
618
+ </div>
619
+ </section>
620
+
621
+ {/* IMAP Settings */}
622
+ <section>
623
+ <div className="flex items-center justify-between mb-3">
624
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider">
625
+ Incoming Mail (IMAP)
626
+ </h3>
627
+ {selectedPreset && (
628
+ <button
629
+ type="button"
630
+ onClick={() => setAdvancedOpen((v) => !v)}
631
+ className="text-xs text-fg-muted hover:text-fg transition-colors"
632
+ >
633
+ {advancedOpen ? "Use preset settings" : "Advanced"}
634
+ </button>
635
+ )}
636
+ </div>
637
+ {serversLocked && (
638
+ <p className="text-xs text-fg-muted mb-3">
639
+ Server settings are pre-filled for {selectedPreset?.label} and
640
+ locked. Choose Advanced to edit them by hand.
641
+ </p>
642
+ )}
643
+ <div className="space-y-3">
644
+ <div>
645
+ <label
646
+ htmlFor="imap-host"
647
+ className="text-sm font-medium mb-1.5 block"
648
+ >
649
+ Server
650
+ </label>
651
+ <Input
652
+ id="imap-host"
653
+ {...form.register("imapHost")}
654
+ readOnly={serversLocked}
655
+ placeholder="imap.example.com"
656
+ />
657
+ {form.formState.errors.imapHost && (
658
+ <p className="text-sm text-danger mt-1">
659
+ {form.formState.errors.imapHost.message}
660
+ </p>
661
+ )}
662
+ </div>
663
+ <div className="grid grid-cols-2 gap-3">
664
+ <div>
665
+ <label
666
+ htmlFor="imap-port"
667
+ className="text-sm font-medium mb-1.5 block"
668
+ >
669
+ Port
670
+ </label>
671
+ <Input
672
+ id="imap-port"
673
+ {...form.register("imapPort")}
674
+ readOnly={serversLocked}
675
+ type="number"
676
+ />
677
+ </div>
678
+ <div>
679
+ <span className="text-sm font-medium mb-1.5 block">
680
+ Security
681
+ </span>
682
+ <div className="space-y-1.5 mt-2">
683
+ <label className="flex items-center gap-2 text-sm">
684
+ <input
685
+ type="radio"
686
+ disabled={serversLocked}
687
+ checked={form.watch("imapTls")}
688
+ onChange={() => handleSecurityChange("tls")}
689
+ />
690
+ TLS (Port 993)
691
+ </label>
692
+ <label className="flex items-center gap-2 text-sm">
693
+ <input
694
+ type="radio"
695
+ disabled={serversLocked}
696
+ checked={
697
+ !form.watch("imapTls") && form.watch("imapStartTls")
698
+ }
699
+ onChange={() => handleSecurityChange("starttls")}
700
+ />
701
+ STARTTLS (Port 143)
702
+ </label>
703
+ <label className="flex items-center gap-2 text-sm">
704
+ <input
705
+ type="radio"
706
+ disabled={serversLocked}
707
+ checked={
708
+ !form.watch("imapTls") && !form.watch("imapStartTls")
709
+ }
710
+ onChange={() => handleSecurityChange("none")}
711
+ />
712
+ None (Unencrypted)
713
+ </label>
714
+ </div>
715
+ </div>
716
+ </div>
717
+ <Button
718
+ type="button"
719
+ variant="secondary"
720
+ onClick={handleTestImap}
721
+ disabled={testMutation.isPending}
722
+ className="w-full"
723
+ icon={
724
+ testMutation.isPending ? (
725
+ <Loader2 className="size-4 animate-spin" />
726
+ ) : undefined
727
+ }
728
+ >
729
+ {testMutation.isPending ? "Testing..." : "Test IMAP Connection"}
730
+ </Button>
731
+ {testMutation.data && (
732
+ <div
733
+ className={cn(
734
+ "p-2 rounded-md text-sm",
735
+ testMutation.data.imapSuccess
736
+ ? "bg-positive/10 text-positive"
737
+ : "bg-danger-soft text-danger",
738
+ )}
739
+ >
740
+ {testMutation.data.imapSuccess ? (
741
+ <Check className="inline size-4 mr-1" />
742
+ ) : (
743
+ <X className="inline size-4 mr-1" />
744
+ )}
745
+ {testMutation.data.imapSuccess ? "IMAP Connected" : imapError}
746
+ </div>
747
+ )}
748
+ </div>
749
+ </section>
750
+
751
+ {/* SMTP Settings */}
752
+ <section ref={smtpSectionRef} data-testid="smtp-section">
753
+ <div className="flex items-center justify-between mb-3">
754
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider">
755
+ Outgoing Mail (SMTP)
756
+ </h3>
757
+ {!serversLocked && (
758
+ <button
759
+ type="button"
760
+ onClick={handlePrefillFromImap}
761
+ disabled={!form.watch("imapHost")}
762
+ className="text-xs text-fg-muted hover:text-fg transition-colors disabled:opacity-50"
763
+ >
764
+ Pre-fill from IMAP
765
+ </button>
766
+ )}
767
+ </div>
768
+ <div className="space-y-3">
769
+ <div>
770
+ <label
771
+ htmlFor="smtp-host"
772
+ className="text-sm font-medium mb-1.5 block"
773
+ >
774
+ Server
775
+ </label>
776
+ <Input
777
+ id="smtp-host"
778
+ {...form.register("smtpHost")}
779
+ readOnly={serversLocked}
780
+ placeholder="smtp.example.com"
781
+ />
782
+ </div>
783
+ <div className="grid grid-cols-2 gap-3">
784
+ <div>
785
+ <label
786
+ htmlFor="smtp-port"
787
+ className="text-sm font-medium mb-1.5 block"
788
+ >
789
+ Port
790
+ </label>
791
+ <Input
792
+ id="smtp-port"
793
+ {...form.register("smtpPort")}
794
+ readOnly={serversLocked}
795
+ type="number"
796
+ />
797
+ </div>
798
+ <div>
799
+ <span className="text-sm font-medium mb-1.5 block">
800
+ Security
801
+ </span>
802
+ <div className="space-y-1.5 mt-2">
803
+ <label className="flex items-center gap-2 text-sm">
804
+ <input
805
+ type="radio"
806
+ disabled={serversLocked}
807
+ checked={form.watch("smtpTls") === true}
808
+ onChange={() => handleSmtpSecurityChange("tls")}
809
+ />
810
+ TLS (Port 465)
811
+ </label>
812
+ <label className="flex items-center gap-2 text-sm">
813
+ <input
814
+ type="radio"
815
+ disabled={serversLocked}
816
+ checked={
817
+ !form.watch("smtpTls") &&
818
+ form.watch("smtpStartTls") === true
819
+ }
820
+ onChange={() => handleSmtpSecurityChange("starttls")}
821
+ />
822
+ STARTTLS (Port 587)
823
+ </label>
824
+ <label className="flex items-center gap-2 text-sm">
825
+ <input
826
+ type="radio"
827
+ disabled={serversLocked}
828
+ checked={
829
+ !form.watch("smtpTls") && !form.watch("smtpStartTls")
830
+ }
831
+ onChange={() => handleSmtpSecurityChange("none")}
832
+ />
833
+ None (Unencrypted)
834
+ </label>
835
+ </div>
836
+ </div>
837
+ </div>
838
+ <label className="flex items-center gap-2 text-sm">
839
+ <input
840
+ type="checkbox"
841
+ {...form.register("useDifferentSmtpCreds")}
842
+ />
843
+ Use different credentials for SMTP
844
+ </label>
845
+ {form.watch("useDifferentSmtpCreds") && (
846
+ <>
847
+ <div>
848
+ <label
849
+ htmlFor="smtp-username"
850
+ className="text-sm font-medium mb-1.5 block"
851
+ >
852
+ SMTP Username
853
+ </label>
854
+ <Input
855
+ id="smtp-username"
856
+ {...form.register("smtpUsername")}
857
+ />
858
+ </div>
859
+ <div>
860
+ <label
861
+ htmlFor="smtp-password"
862
+ className="text-sm font-medium mb-1.5 block"
863
+ >
864
+ SMTP Password
865
+ </label>
866
+ <Input
867
+ id="smtp-password"
868
+ {...form.register("smtpPassword")}
869
+ type="password"
870
+ />
871
+ </div>
872
+ </>
873
+ )}
874
+ <Button
875
+ type="button"
876
+ variant="secondary"
877
+ onClick={handleTestSmtp}
878
+ disabled={testMutation.isPending || !form.watch("smtpHost")}
879
+ className="w-full"
880
+ icon={
881
+ testMutation.isPending ? (
882
+ <Loader2 className="size-4 animate-spin" />
883
+ ) : undefined
884
+ }
885
+ >
886
+ {testMutation.isPending ? "Testing..." : "Test SMTP Connection"}
887
+ </Button>
888
+ {testMutation.data?.smtpSuccess !== undefined && (
889
+ <div
890
+ className={cn(
891
+ "p-2 rounded-md text-sm",
892
+ testMutation.data.smtpSuccess
893
+ ? "bg-positive/10 text-positive"
894
+ : "bg-danger-soft text-danger",
895
+ )}
896
+ >
897
+ {testMutation.data.smtpSuccess ? (
898
+ <Check className="inline size-4 mr-1" />
899
+ ) : (
900
+ <X className="inline size-4 mr-1" />
901
+ )}
902
+ {testMutation.data.smtpSuccess ? "SMTP Connected" : smtpError}
903
+ </div>
904
+ )}
905
+ </div>
906
+ </section>
907
+
908
+ {isEditing && (
909
+ <section>
910
+ <h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
911
+ Signature
912
+ </h3>
913
+ <div className="space-y-3">
914
+ <div>
915
+ <label
916
+ htmlFor="email-sig-edit"
917
+ className="text-sm font-medium mb-1.5 block"
918
+ >
919
+ Email Signature
920
+ </label>
921
+ <textarea
922
+ id="email-sig-edit"
923
+ value={signatureText}
924
+ onChange={(e) => setSignatureText(e.target.value)}
925
+ onBlur={handleSignatureBlur}
926
+ rows={5}
927
+ className="w-full px-3 py-2 border border-line rounded-md bg-surface-sunken text-sm text-fg placeholder:text-fg-subtle focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30 transition-colors resize-y outline-none"
928
+ placeholder="Enter your email signature..."
929
+ />
930
+ <p className="text-xs text-fg-muted mt-1">
931
+ {isSignatureSaving
932
+ ? "Saving signature..."
933
+ : "This signature will be appended to new emails."}
934
+ </p>
935
+ </div>
936
+ </div>
937
+ </section>
938
+ )}
939
+ </form>
940
+ </SlidePanel>
941
+ );
942
+ };