@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.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildCreateFilterInput,
|
|
5
|
+
buildOrganizeInput,
|
|
6
|
+
hasCommittableAction,
|
|
7
|
+
NO_ACTION,
|
|
8
|
+
type OrganizeDraft,
|
|
9
|
+
} from "./organize-model";
|
|
10
|
+
|
|
11
|
+
const baseDraft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
|
|
12
|
+
matchOperator: "And",
|
|
13
|
+
literalClauses: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("hasCommittableAction", () => {
|
|
18
|
+
it("is false when no move target is chosen — labeling has no backend yet, so a keep-in-place draft has nothing to commit", () => {
|
|
19
|
+
assert.equal(hasCommittableAction(baseDraft()), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is false for the None sentinel", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
hasCommittableAction(baseDraft({ moveMailboxId: NO_ACTION })),
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is true once a real destination mailbox is chosen", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
hasCommittableAction(baseDraft({ moveMailboxId: "mbx-1" })),
|
|
32
|
+
true,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("buildOrganizeInput", () => {
|
|
38
|
+
it("carries the anchor and defaults the action to None when no move is set", () => {
|
|
39
|
+
const input = buildOrganizeInput(baseDraft({ anchorMessageId: "msg-1" }));
|
|
40
|
+
assert.equal(input.anchorMessageId, "msg-1");
|
|
41
|
+
assert.equal(input.actionMailboxId, NO_ACTION);
|
|
42
|
+
assert.equal(input.actionLabelId, NO_ACTION);
|
|
43
|
+
assert.equal(input.matchOperator, "And");
|
|
44
|
+
assert.deepEqual(input.literalClauses, []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("omits anchorMessageId entirely for a purely-literal input", () => {
|
|
48
|
+
const input = buildOrganizeInput(
|
|
49
|
+
baseDraft({
|
|
50
|
+
literalClauses: [{ field: "From", value: "airbnb.com" }],
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
assert.equal("anchorMessageId" in input, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("labels the label action None even when a move target is set — label writes have no endpoint", () => {
|
|
57
|
+
const input = buildOrganizeInput(
|
|
58
|
+
baseDraft({ anchorMessageId: "msg-1", moveMailboxId: "mbx-9" }),
|
|
59
|
+
);
|
|
60
|
+
assert.equal(input.actionMailboxId, "mbx-9");
|
|
61
|
+
assert.equal(input.actionLabelId, NO_ACTION);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("buildCreateFilterInput", () => {
|
|
66
|
+
it("builds a Standing filter with no expiresAt", () => {
|
|
67
|
+
const input = buildCreateFilterInput(
|
|
68
|
+
baseDraft({ anchorMessageId: "msg-1", moveMailboxId: "mbx-2" }),
|
|
69
|
+
"standing",
|
|
70
|
+
"Travel",
|
|
71
|
+
);
|
|
72
|
+
assert.equal(input.scope, "Standing");
|
|
73
|
+
assert.equal("expiresAt" in input, false);
|
|
74
|
+
assert.equal(input.name, "Travel");
|
|
75
|
+
assert.equal(input.actionMailboxId, "mbx-2");
|
|
76
|
+
assert.equal(input.actionLabelId, NO_ACTION);
|
|
77
|
+
assert.equal(input.anchorMessageId, "msg-1");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("builds a Temporary filter that carries expiresAt", () => {
|
|
81
|
+
const input = buildCreateFilterInput(
|
|
82
|
+
baseDraft({
|
|
83
|
+
moveMailboxId: "mbx-2",
|
|
84
|
+
expiresAt: "2026-07-16T23:59:59+02:00",
|
|
85
|
+
}),
|
|
86
|
+
"temporary",
|
|
87
|
+
"Trip",
|
|
88
|
+
);
|
|
89
|
+
assert.equal(input.scope, "Temporary");
|
|
90
|
+
assert.equal(input.expiresAt, "2026-07-16T23:59:59+02:00");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("never sends a client-side ttl — the server derives it from expiresAt", () => {
|
|
94
|
+
const input = buildCreateFilterInput(
|
|
95
|
+
baseDraft({
|
|
96
|
+
moveMailboxId: "mbx-2",
|
|
97
|
+
expiresAt: "2026-07-16T23:59:59+02:00",
|
|
98
|
+
}),
|
|
99
|
+
"temporary",
|
|
100
|
+
"Trip",
|
|
101
|
+
);
|
|
102
|
+
assert.equal("ttl" in input, false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapCreateFilterInput,
|
|
3
|
+
RemitImapFilterClause,
|
|
4
|
+
RemitImapFilterMatchOperator,
|
|
5
|
+
RemitImapFilterScope,
|
|
6
|
+
RemitImapOrganizeInput,
|
|
7
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The four commit scopes the smart-organize sentence offers (RFC 034 recap):
|
|
11
|
+
*
|
|
12
|
+
* - `just-these` — a one-time action on the current selection. Existing
|
|
13
|
+
* message-action API, nothing persisted.
|
|
14
|
+
* - `all-like-these` — a one-time retroactive back-apply across the matching
|
|
15
|
+
* corpus (POST /organize job). Nothing standing persisted.
|
|
16
|
+
* - `standing` — "these and new mail like this": a permanent `Filter`.
|
|
17
|
+
* - `temporary` — "until <date>": a `Filter` that expires on its own.
|
|
18
|
+
*/
|
|
19
|
+
export type OrganizeScope =
|
|
20
|
+
| "just-these"
|
|
21
|
+
| "all-like-these"
|
|
22
|
+
| "standing"
|
|
23
|
+
| "temporary";
|
|
24
|
+
|
|
25
|
+
/** The `"None"` sentinel the API uses for an absent label / move action. */
|
|
26
|
+
export const NO_ACTION = "None";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The user's in-progress organize decision, independent of which scope they
|
|
30
|
+
* land on. The anchor and predicate drive the match set; `moveMailboxId` is the
|
|
31
|
+
* one committable action today (labeling has no backend yet — see
|
|
32
|
+
* `labelPlaceholder`).
|
|
33
|
+
*/
|
|
34
|
+
export interface OrganizeDraft {
|
|
35
|
+
/** Semantic anchor — "mail like this one". The first selected message. */
|
|
36
|
+
anchorMessageId?: string;
|
|
37
|
+
/** How `literalClauses` combine. Ignored when there are none. */
|
|
38
|
+
matchOperator: RemitImapFilterMatchOperator;
|
|
39
|
+
/** Literal from/subject/has-words clauses (RFC 031). */
|
|
40
|
+
literalClauses: RemitImapFilterClause[];
|
|
41
|
+
/**
|
|
42
|
+
* Destination the match set is moved into — the exclusive action (RFC 034
|
|
43
|
+
* Decision 3.1). Absent means "keep where they are": no move action.
|
|
44
|
+
*/
|
|
45
|
+
moveMailboxId?: string;
|
|
46
|
+
/**
|
|
47
|
+
* ISO 8601 date-time with zone offset. Present only for the `temporary`
|
|
48
|
+
* scope; a plain picked date (RFC 034 non-goal: no event-based expiry).
|
|
49
|
+
*/
|
|
50
|
+
expiresAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the draft carries a committable action. Labeling is not wired yet
|
|
55
|
+
* (no Label API — RFC 030's `Label`/`MessageLabel` entities exist in TypeSpec
|
|
56
|
+
* but have no CRUD endpoint), so a move target is the only real action. A draft
|
|
57
|
+
* with no move target has nothing to commit; the caller disables the CTA and
|
|
58
|
+
* says why (ux.md).
|
|
59
|
+
*/
|
|
60
|
+
export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
|
|
61
|
+
draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the read-only preview / back-apply matcher input. The action fields do
|
|
65
|
+
* not affect which messages match — the preview returns exactly the set a job
|
|
66
|
+
* with the same predicate would apply to — so a widen preview can pass this
|
|
67
|
+
* before the user has chosen a folder.
|
|
68
|
+
*/
|
|
69
|
+
export const buildOrganizeInput = (
|
|
70
|
+
draft: OrganizeDraft,
|
|
71
|
+
): RemitImapOrganizeInput => ({
|
|
72
|
+
...(draft.anchorMessageId ? { anchorMessageId: draft.anchorMessageId } : {}),
|
|
73
|
+
matchOperator: draft.matchOperator,
|
|
74
|
+
literalClauses: draft.literalClauses,
|
|
75
|
+
actionLabelId: NO_ACTION,
|
|
76
|
+
actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `createFilter` body for a standing or temporary filter. `ttl` is
|
|
81
|
+
* derived server-side from `expiresAt`; `expiresAt` is sent only for the
|
|
82
|
+
* temporary scope (RFC 034 Decision 1.3).
|
|
83
|
+
*/
|
|
84
|
+
export const buildCreateFilterInput = (
|
|
85
|
+
draft: OrganizeDraft,
|
|
86
|
+
scope: Extract<OrganizeScope, "standing" | "temporary">,
|
|
87
|
+
name: string,
|
|
88
|
+
): RemitImapCreateFilterInput => {
|
|
89
|
+
const filterScope: RemitImapFilterScope =
|
|
90
|
+
scope === "standing" ? "Standing" : "Temporary";
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
scope: filterScope,
|
|
94
|
+
...(scope === "temporary" && draft.expiresAt
|
|
95
|
+
? { expiresAt: draft.expiresAt }
|
|
96
|
+
: {}),
|
|
97
|
+
matchOperator: draft.matchOperator,
|
|
98
|
+
literalClauses: draft.literalClauses,
|
|
99
|
+
actionLabelId: NO_ACTION,
|
|
100
|
+
actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
|
|
101
|
+
...(draft.anchorMessageId
|
|
102
|
+
? { anchorMessageId: draft.anchorMessageId }
|
|
103
|
+
: {}),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isTerminalJobState, nextPollDelayMs } from "./organize-poll";
|
|
4
|
+
|
|
5
|
+
describe("isTerminalJobState", () => {
|
|
6
|
+
it("stops on Complete and Failed", () => {
|
|
7
|
+
assert.equal(isTerminalJobState("Complete"), true);
|
|
8
|
+
assert.equal(isTerminalJobState("Failed"), true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("keeps polling on Pending and Running", () => {
|
|
12
|
+
assert.equal(isTerminalJobState("Pending"), false);
|
|
13
|
+
assert.equal(isTerminalJobState("Running"), false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps polling while the state is unknown", () => {
|
|
17
|
+
assert.equal(isTerminalJobState(undefined), false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("nextPollDelayMs", () => {
|
|
22
|
+
it("backs off exponentially from 1s", () => {
|
|
23
|
+
assert.equal(nextPollDelayMs(0), 1_000);
|
|
24
|
+
assert.equal(nextPollDelayMs(1), 2_000);
|
|
25
|
+
assert.equal(nextPollDelayMs(2), 4_000);
|
|
26
|
+
assert.equal(nextPollDelayMs(3), 8_000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("caps the delay at 15s", () => {
|
|
30
|
+
assert.equal(nextPollDelayMs(10), 15_000);
|
|
31
|
+
assert.equal(nextPollDelayMs(100), 15_000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats a negative attempt as the first delay", () => {
|
|
35
|
+
assert.equal(nextPollDelayMs(-5), 1_000);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { RemitImapOrganizeJobState } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/** States a back-apply job stops at — polling ends here (RFC 034, #1278). */
|
|
4
|
+
const TERMINAL_STATES: ReadonlySet<RemitImapOrganizeJobState> = new Set([
|
|
5
|
+
"Complete",
|
|
6
|
+
"Failed",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export const isTerminalJobState = (
|
|
10
|
+
state: RemitImapOrganizeJobState | undefined,
|
|
11
|
+
): boolean => state !== undefined && TERMINAL_STATES.has(state);
|
|
12
|
+
|
|
13
|
+
const BASE_POLL_MS = 1_000;
|
|
14
|
+
const MAX_POLL_MS = 15_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Exponential backoff for job polling: 1s, 2s, 4s, 8s, capped at 15s. `attempt`
|
|
18
|
+
* is the number of polls already made (0 for the first delay). Backoff keeps a
|
|
19
|
+
* long-running back-apply from hammering the job endpoint while staying
|
|
20
|
+
* responsive for the common fast case — and there is never a client-side loop
|
|
21
|
+
* over message pages (remit is data-heavy; the corpus is queried once, server
|
|
22
|
+
* side, by the job itself).
|
|
23
|
+
*/
|
|
24
|
+
export const nextPollDelayMs = (attempt: number): number => {
|
|
25
|
+
const clampedAttempt = Math.max(0, attempt);
|
|
26
|
+
const delay = BASE_POLL_MS * 2 ** clampedAttempt;
|
|
27
|
+
return Math.min(delay, MAX_POLL_MS);
|
|
28
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
describeOutboxStatus,
|
|
5
|
+
isOutboxListRow,
|
|
6
|
+
isUnsendableStatus,
|
|
7
|
+
} from "./outbox-status.js";
|
|
8
|
+
|
|
9
|
+
describe("describeOutboxStatus", () => {
|
|
10
|
+
test("returns null for draft", () => {
|
|
11
|
+
assert.equal(describeOutboxStatus("draft"), null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("blocked is rendered with a warning tone, not success", () => {
|
|
15
|
+
const desc = describeOutboxStatus("blocked");
|
|
16
|
+
assert.ok(desc);
|
|
17
|
+
assert.equal(desc.tone, "warning");
|
|
18
|
+
assert.equal(desc.label, "Blocked");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("sent is success tone", () => {
|
|
22
|
+
const desc = describeOutboxStatus("sent");
|
|
23
|
+
assert.ok(desc);
|
|
24
|
+
assert.equal(desc.tone, "success");
|
|
25
|
+
assert.equal(desc.label, "Sent");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("failed is error tone (distinct from blocked)", () => {
|
|
29
|
+
const desc = describeOutboxStatus("failed");
|
|
30
|
+
assert.ok(desc);
|
|
31
|
+
assert.equal(desc.tone, "error");
|
|
32
|
+
assert.notEqual(desc.tone, describeOutboxStatus("blocked")?.tone);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isUnsendableStatus", () => {
|
|
37
|
+
test("sent must NOT be flagged as unsendable — never show error subtitle on success (issue #192)", () => {
|
|
38
|
+
assert.equal(isUnsendableStatus("sent"), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("failed and blocked are unsendable", () => {
|
|
42
|
+
assert.equal(isUnsendableStatus("failed"), true);
|
|
43
|
+
assert.equal(isUnsendableStatus("blocked"), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("queued and sending are not unsendable", () => {
|
|
47
|
+
assert.equal(isUnsendableStatus("queued"), false);
|
|
48
|
+
assert.equal(isUnsendableStatus("sending"), false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("isOutboxListRow", () => {
|
|
53
|
+
test("hides draft and sent rows", () => {
|
|
54
|
+
assert.equal(isOutboxListRow("draft"), false);
|
|
55
|
+
assert.equal(isOutboxListRow("sent"), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shows queued, sending, failed, blocked", () => {
|
|
59
|
+
assert.equal(isOutboxListRow("queued"), true);
|
|
60
|
+
assert.equal(isOutboxListRow("sending"), true);
|
|
61
|
+
assert.equal(isOutboxListRow("failed"), true);
|
|
62
|
+
assert.equal(isOutboxListRow("blocked"), true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RemitImapOutboxMessageStatus } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visual states for outbox messages.
|
|
5
|
+
*
|
|
6
|
+
* `sent` is included for completeness (the detail view of a sent message
|
|
7
|
+
* uses it before the row gets purged) but the outbox list never renders
|
|
8
|
+
* `sent` rows — they are filtered out (issue #193).
|
|
9
|
+
*/
|
|
10
|
+
export type OutboxDisplayStatus = Exclude<
|
|
11
|
+
RemitImapOutboxMessageStatus,
|
|
12
|
+
"draft"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
export type OutboxStatusTone =
|
|
16
|
+
| "success"
|
|
17
|
+
| "info"
|
|
18
|
+
| "warning"
|
|
19
|
+
| "error"
|
|
20
|
+
| "neutral";
|
|
21
|
+
|
|
22
|
+
export interface OutboxStatusDescriptor {
|
|
23
|
+
label: string;
|
|
24
|
+
tone: OutboxStatusTone;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATUS_DESCRIPTORS: Record<OutboxDisplayStatus, OutboxStatusDescriptor> =
|
|
28
|
+
{
|
|
29
|
+
queued: { label: "Queued", tone: "neutral" },
|
|
30
|
+
sending: { label: "Sending…", tone: "info" },
|
|
31
|
+
sent: { label: "Sent", tone: "success" },
|
|
32
|
+
failed: { label: "Failed", tone: "error" },
|
|
33
|
+
blocked: { label: "Blocked", tone: "warning" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Describe an outbox status for UI rendering.
|
|
38
|
+
*
|
|
39
|
+
* `failed` and `blocked` are both terminal non-success states but carry
|
|
40
|
+
* different remediation: `failed` may auto-retry on a transient error, and
|
|
41
|
+
* is the appropriate label for SMTP auth/connection errors. `blocked` means
|
|
42
|
+
* the account is misconfigured (e.g. no SMTP host) — the user must fix the
|
|
43
|
+
* account before any retry can succeed.
|
|
44
|
+
*
|
|
45
|
+
* Returns null for `draft` (drafts live in a separate view).
|
|
46
|
+
*/
|
|
47
|
+
export const describeOutboxStatus = (
|
|
48
|
+
status: RemitImapOutboxMessageStatus,
|
|
49
|
+
): OutboxStatusDescriptor | null => {
|
|
50
|
+
if (status === "draft") return null;
|
|
51
|
+
return STATUS_DESCRIPTORS[status];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a status carries a `lastError` worth surfacing in the UI.
|
|
56
|
+
*
|
|
57
|
+
* Critically: `sent` is NOT included. A successful send must never display
|
|
58
|
+
* a stale lastError — that is the contradictory "green check + 'SMTP not
|
|
59
|
+
* configured'" UI bug from issue #192.
|
|
60
|
+
*/
|
|
61
|
+
export const isUnsendableStatus = (
|
|
62
|
+
status: RemitImapOutboxMessageStatus,
|
|
63
|
+
): boolean => status === "failed" || status === "blocked";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the outbox list view should render this row.
|
|
67
|
+
*
|
|
68
|
+
* `draft` belongs in the Drafts view. `sent` rows are deleted by the IMAP
|
|
69
|
+
* APPEND handler (issue #178) — but until that happens we hide them from
|
|
70
|
+
* the Outbox list so a successfully-sent message never appears in two
|
|
71
|
+
* places at once (issue #193).
|
|
72
|
+
*/
|
|
73
|
+
export const isOutboxListRow = (
|
|
74
|
+
status: RemitImapOutboxMessageStatus,
|
|
75
|
+
): boolean => status !== "draft" && status !== "sent";
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { TElement, TText, Value } from "platejs";
|
|
2
|
+
|
|
3
|
+
type PlateNode = TElement | TText;
|
|
4
|
+
|
|
5
|
+
const isText = (node: PlateNode): node is TText =>
|
|
6
|
+
"text" in node && !("children" in node);
|
|
7
|
+
|
|
8
|
+
const escapeHtml = (text: string): string =>
|
|
9
|
+
text
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
|
|
15
|
+
const serializeTextNode = (node: TText): string => {
|
|
16
|
+
let html = escapeHtml(node.text);
|
|
17
|
+
if (node.bold) html = `<strong>${html}</strong>`;
|
|
18
|
+
if (node.italic) html = `<em>${html}</em>`;
|
|
19
|
+
return html;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const serializeElement = (node: TElement): string => {
|
|
23
|
+
const children = node.children
|
|
24
|
+
.map((child) =>
|
|
25
|
+
isText(child as PlateNode)
|
|
26
|
+
? serializeTextNode(child as TText)
|
|
27
|
+
: serializeElement(child as TElement),
|
|
28
|
+
)
|
|
29
|
+
.join("");
|
|
30
|
+
|
|
31
|
+
switch (node.type) {
|
|
32
|
+
case "blockquote":
|
|
33
|
+
return `<blockquote>${children}</blockquote>`;
|
|
34
|
+
case "a": {
|
|
35
|
+
const url = (node as TElement & { url?: string }).url ?? "";
|
|
36
|
+
return `<a href="${escapeHtml(url)}">${children}</a>`;
|
|
37
|
+
}
|
|
38
|
+
default:
|
|
39
|
+
return `<p>${children}</p>`;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const plateValueToHtml = (value: Value): string =>
|
|
44
|
+
value.map(serializeElement).join("");
|
|
45
|
+
|
|
46
|
+
const extractText = (node: PlateNode): string => {
|
|
47
|
+
if (isText(node)) return node.text;
|
|
48
|
+
return (node.children as PlateNode[]).map(extractText).join("");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const plateValueToText = (value: Value): string =>
|
|
52
|
+
value.map(extractText).join("\n");
|
|
53
|
+
|
|
54
|
+
const parseTextWithMarks = (
|
|
55
|
+
textContent: string,
|
|
56
|
+
marks: Record<string, boolean>,
|
|
57
|
+
): TText => ({
|
|
58
|
+
text: textContent,
|
|
59
|
+
...marks,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const getMarksFromElement = (el: Element): Record<string, boolean> => {
|
|
63
|
+
const marks: Record<string, boolean> = {};
|
|
64
|
+
const tag = el.tagName.toLowerCase();
|
|
65
|
+
if (tag === "strong" || tag === "b") marks.bold = true;
|
|
66
|
+
if (tag === "em" || tag === "i") marks.italic = true;
|
|
67
|
+
return marks;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const parseInlineChildren = (
|
|
71
|
+
node: Node,
|
|
72
|
+
inheritedMarks: Record<string, boolean> = {},
|
|
73
|
+
): TText[] => {
|
|
74
|
+
const results: TText[] = [];
|
|
75
|
+
|
|
76
|
+
for (const child of Array.from(node.childNodes)) {
|
|
77
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
78
|
+
results.push(parseTextWithMarks(child.textContent ?? "", inheritedMarks));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
83
|
+
|
|
84
|
+
const el = child as Element;
|
|
85
|
+
const tag = el.tagName.toLowerCase();
|
|
86
|
+
|
|
87
|
+
if (tag === "a") {
|
|
88
|
+
const linkElement: TElement & { url: string } = {
|
|
89
|
+
type: "a",
|
|
90
|
+
url: el.getAttribute("href") ?? "",
|
|
91
|
+
children: parseInlineChildren(el, inheritedMarks),
|
|
92
|
+
};
|
|
93
|
+
results.push(linkElement as unknown as TText);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const marks = { ...inheritedMarks, ...getMarksFromElement(el) };
|
|
98
|
+
results.push(...parseInlineChildren(el, marks));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return results;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const parseBlockElement = (el: Element): TElement => {
|
|
105
|
+
const tag = el.tagName.toLowerCase();
|
|
106
|
+
|
|
107
|
+
if (tag === "blockquote") {
|
|
108
|
+
const children = Array.from(el.children);
|
|
109
|
+
if (children.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
type: "blockquote",
|
|
112
|
+
children: children.map(parseBlockElement),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const inlineChildren = parseInlineChildren(el);
|
|
116
|
+
return {
|
|
117
|
+
type: "blockquote",
|
|
118
|
+
children: inlineChildren.length > 0 ? inlineChildren : [{ text: "" }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (tag === "a") {
|
|
123
|
+
return {
|
|
124
|
+
type: "a",
|
|
125
|
+
url: el.getAttribute("href") ?? "",
|
|
126
|
+
children: parseInlineChildren(el),
|
|
127
|
+
} as TElement & { url: string };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const inlineChildren = parseInlineChildren(el);
|
|
131
|
+
return {
|
|
132
|
+
type: "p",
|
|
133
|
+
children: inlineChildren.length > 0 ? inlineChildren : [{ text: "" }],
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const htmlToPlateValue = (html: string): Value => {
|
|
138
|
+
const parser = new DOMParser();
|
|
139
|
+
const doc = parser.parseFromString(html, "text/html");
|
|
140
|
+
const body = doc.body;
|
|
141
|
+
|
|
142
|
+
const blockElements = Array.from(body.childNodes).reduce<TElement[]>(
|
|
143
|
+
(acc, node) => {
|
|
144
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
145
|
+
const text = node.textContent ?? "";
|
|
146
|
+
if (text.trim()) {
|
|
147
|
+
acc.push({ type: "p", children: [{ text }] });
|
|
148
|
+
}
|
|
149
|
+
return acc;
|
|
150
|
+
}
|
|
151
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
152
|
+
acc.push(parseBlockElement(node as Element));
|
|
153
|
+
}
|
|
154
|
+
return acc;
|
|
155
|
+
},
|
|
156
|
+
[],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (blockElements.length === 0) {
|
|
160
|
+
return [{ type: "p", children: [{ text: "" }] }];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return blockElements;
|
|
164
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
getPresetById,
|
|
5
|
+
PROVIDER_PRESETS,
|
|
6
|
+
type ProviderPreset,
|
|
7
|
+
presetIdForEmail,
|
|
8
|
+
} from "./provider-presets.js";
|
|
9
|
+
|
|
10
|
+
interface ExpectedSettings {
|
|
11
|
+
imap: { host: string; port: number; security: string };
|
|
12
|
+
smtp: { host: string; port: number; security: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const EXPECTED: Record<string, ExpectedSettings> = {
|
|
16
|
+
icloud: {
|
|
17
|
+
imap: { host: "imap.mail.me.com", port: 993, security: "tls" },
|
|
18
|
+
smtp: { host: "smtp.mail.me.com", port: 587, security: "starttls" },
|
|
19
|
+
},
|
|
20
|
+
yahoo: {
|
|
21
|
+
imap: { host: "imap.mail.yahoo.com", port: 993, security: "tls" },
|
|
22
|
+
smtp: { host: "smtp.mail.yahoo.com", port: 465, security: "tls" },
|
|
23
|
+
},
|
|
24
|
+
aol: {
|
|
25
|
+
imap: { host: "imap.aol.com", port: 993, security: "tls" },
|
|
26
|
+
smtp: { host: "smtp.aol.com", port: 465, security: "tls" },
|
|
27
|
+
},
|
|
28
|
+
fastmail: {
|
|
29
|
+
imap: { host: "imap.fastmail.com", port: 993, security: "tls" },
|
|
30
|
+
smtp: { host: "smtp.fastmail.com", port: 465, security: "tls" },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("provider presets", () => {
|
|
35
|
+
it("exposes exactly the four documented providers", () => {
|
|
36
|
+
const ids = PROVIDER_PRESETS.map((p) => p.id).sort();
|
|
37
|
+
assert.deepEqual(ids, ["aol", "fastmail", "icloud", "yahoo"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const preset of PROVIDER_PRESETS) {
|
|
41
|
+
describe(preset.label, () => {
|
|
42
|
+
const expected = EXPECTED[preset.id];
|
|
43
|
+
|
|
44
|
+
it("matches the exact IMAP settings from the table", () => {
|
|
45
|
+
assert.deepEqual(preset.imap, expected.imap);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("matches the exact SMTP settings from the table", () => {
|
|
49
|
+
assert.deepEqual(preset.smtp, expected.smtp);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("uses the full email as username", () => {
|
|
53
|
+
assert.equal(preset.username, "full-email");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("has password help text", () => {
|
|
57
|
+
assert.ok(preset.passwordHelp.text.length > 0);
|
|
58
|
+
assert.match(preset.passwordHelp.text, /app[\w-]* password/i);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("has a valid https app-password url", () => {
|
|
62
|
+
const url = new URL(preset.passwordHelp.url);
|
|
63
|
+
assert.equal(url.protocol, "https:");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it("looks up a preset by id", () => {
|
|
69
|
+
const preset: ProviderPreset | undefined = getPresetById("icloud");
|
|
70
|
+
assert.ok(preset);
|
|
71
|
+
assert.equal(preset.label, "iCloud");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns undefined for an unknown id", () => {
|
|
75
|
+
assert.equal(getPresetById("custom"), undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("presetIdForEmail", () => {
|
|
79
|
+
it("pre-selects a preset for a known provider domain", () => {
|
|
80
|
+
assert.equal(presetIdForEmail("alice@icloud.com"), "icloud");
|
|
81
|
+
assert.equal(presetIdForEmail("bob@me.com"), "icloud");
|
|
82
|
+
assert.equal(presetIdForEmail("carol@yahoo.com"), "yahoo");
|
|
83
|
+
assert.equal(presetIdForEmail("dave@aol.com"), "aol");
|
|
84
|
+
assert.equal(presetIdForEmail("erin@fastmail.com"), "fastmail");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is case-insensitive on the domain", () => {
|
|
88
|
+
assert.equal(presetIdForEmail("Alice@iCloud.com"), "icloud");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns Custom for unknown or malformed addresses", () => {
|
|
92
|
+
assert.equal(presetIdForEmail("self@example.com"), "");
|
|
93
|
+
assert.equal(presetIdForEmail("not-an-email"), "");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|