Compare commits

...
47 Commits
Author SHA1 Message Date
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
Linus Rath a019d9e775 chore: update version number to 0.1.48 2026-06-12 17:15:53 +02:00
Linus Rath e6d47b6a16 feat: shared calendars, custom recurrence editor, and file sharing 2026-06-12 17:15:50 +02:00
Linus Rath b10f61a642 chore: update version number to 0.1.47 2026-06-12 15:26:57 +02:00
Linus Rath 752aa719d8 feat: pinch zoom and pan for email body content 2026-06-12 15:26:42 +02:00
Linus Rath 5884a18979 refactor: move date/time/size meta from subject row to sender block 2026-06-11 16:33:59 +02:00
Linus Rath 3ccc83e7ce chore: update version number to 0.1.46 2026-06-11 16:24:55 +02:00
Linus Rath 5dc4ea1a45 refactor: replace three-pane pager with FlatList page-snapping pager 2026-06-11 16:24:43 +02:00
Linus Rath 1bd476e052 chore: update version number to 0.1.45 2026-06-11 15:11:28 +02:00
Linus Rath 41793455f7 fix: seamless email swipe transitions with skeleton loading placeholder 2026-06-11 15:11:09 +02:00
Linus Rath d99cbf1dbb chore: update version number to 0.1.44 2026-06-11 14:39:39 +02:00
Linus Rath 1afd2185d5 feat: three-pane pager for seamless email swipe navigation 2026-06-11 14:39:30 +02:00
Linus Rath 5aa1c88b1e chore: update version number to 0.1.43 2026-06-05 19:25:47 +02:00
Linus Rath ac95a2bc39 feat: QR code sign-in 2026-06-05 19:25:00 +02:00
Linus Rath e75aefeb4f chore: update version number to 0.1.42 2026-06-05 16:38:13 +02:00
Linus Rath 70863e0923 feat: swipe navigation between emails and dark-mode emoji/background re-inversion 2026-06-05 16:35:06 +02:00
Linus Rath b6ecf07369 feat: customizable reader quick actions and shrink-to-fit email scaling 2026-06-05 16:21:00 +02:00
Linus Rath bf5d75d5e9 chore: update version number to 0.1.41 2026-05-30 17:25:51 +02:00
Linus Rath 53520a4236 fix: prevent infinite render loop in contacts selectors 2026-05-30 17:25:47 +02:00
Linus Rath c55f78b5a7 chore: update version number to 0.1.40 2026-05-30 15:33:52 +02:00
Linus Rath 5d47f47c8e feat: add Sieve filters, account security (TOTP), outbox, and EML import 2026-05-30 15:33:41 +02:00
97 changed files with 12377 additions and 1582 deletions
+15 -2
View File
@@ -46,6 +46,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
@@ -268,6 +269,7 @@ export default function App() {
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
@@ -276,10 +278,21 @@ export default function App() {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays });
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
// Drain the offline action queue (outbox) as soon as we have a live session,
// and again whenever the network comes back. The flush itself no-ops when
// there's nothing queued or the client isn't ready.
React.useEffect(() => {
if (!haveLiveSession) return;
void useOutboxStore.getState().flush();
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online) void useOutboxStore.getState().flush();
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
+1 -1
View File
@@ -1 +1 @@
0.1.39
0.1.54
+7
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -21,6 +22,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
+12 -1
View File
@@ -46,7 +46,18 @@ module.exports = {
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
plugins: [
'expo-secure-store',
'@react-native-community/datetimepicker',
'expo-localization',
[
'expo-camera',
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
},
],
],
extra: {
commit: COMMIT,
},
+8 -4
View File
@@ -821,10 +821,13 @@
},
"date_format": {
"label": "Date Format",
"description": "How dates should be displayed",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Custom"
"description": "How dates are shown in the email list",
"smart": "Smart (locale-aware)",
"relative": "Relative (1h ago, 2d ago)",
"full": "Always full date",
"preview_today": "Today:",
"preview_this_week": "This week:",
"preview_older": "Older:"
},
"time_format": {
"label": "Time Format",
@@ -1424,6 +1427,7 @@
"size_bytes": "Size in bytes",
"size_placeholder": "e.g., 1000000",
"system_managed": "System-managed rule",
"origin_external": "External",
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
"open_sieve_editor": "Open raw Sieve editor",
"fetch_error": "Failed to load filters",
+123 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -26,6 +27,7 @@
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -4757,6 +4759,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5204,6 +5212,26 @@
}
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6202,6 +6230,12 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -6318,6 +6352,12 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6819,6 +6859,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -6846,6 +6898,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@@ -8006,6 +8067,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-png": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
@@ -8217,6 +8284,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -8585,6 +8658,27 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -8989,6 +9083,12 @@
"node": ">= 0.8"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9226,6 +9326,21 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -9762,6 +9877,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -32,6 +33,7 @@
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
+31 -5
View File
@@ -42,10 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should send a CalendarEvent/query without server-side filter', async () => {
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
// filter client-side. Asserting on the absence of `filter` keeps that
// contract pinned.
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter (and
// after/before); calendars are restricted via singular `inCalendar`
// conditions and date filtering stays client-side.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -55,9 +55,35 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toBeUndefined();
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].accountId).toBe('acc-1');
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'OR',
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
});
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents([], '', '');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined();
});
});
describe('getEvents', () => {
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(),
currentSession: null as unknown,
},
}));
vi.mock('../blob', () => ({
getDownloadUrl: vi.fn(
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
`https://dl/${accountId ?? 'own'}/${blobId}`,
),
uploadBlob: vi.fn(),
uploadBytes: vi.fn(),
}));
import { jmapClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
createFolder,
deleteFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
getPrincipals,
isCrossAccountId,
isFolder,
setFileNodeShare,
} from '../files';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
function setSession(session: unknown) {
(jmapClient as { currentSession: unknown }).currentSession = session;
}
beforeEach(() => {
vi.clearAllMocks();
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
);
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
},
});
});
describe('isFolder', () => {
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
// 0-byte file that nothing can be parented under.
expect(isFolder({ blobId: null })).toBe(true);
expect(isFolder({ blobId: undefined })).toBe(true);
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
});
});
describe('createFolder', () => {
it('creates a real container node: no blobId/type/size, parentId set', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', 'parent-1');
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
});
it('omits parentId at the root', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
});
});
describe('deleteFileNodes', () => {
it('lets the server cascade into folder children', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
});
await deleteFileNodes(['f1']);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.destroy).toEqual(['f1']);
expect(args.onDestroyRemoveChildren).toBe(true);
});
it('throws when the server refuses a destroy', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
],
});
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
});
});
describe('setFileNodeShare', () => {
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const rights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
await setFileNodeShare('node-1', 'principal-2', rights);
const [calls, using] = mockRequest.mock.calls[0];
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
});
it('sends null rights to revoke access', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
await setFileNodeShare('node-1', 'principal-2', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
});
it('throws when the server rejects or does not confirm the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
],
});
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
'Server did not confirm the share update',
);
});
});
describe('getAllFileNodesAcrossAccounts', () => {
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-2': { name: 'Team', isPersonal: false },
},
});
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
],
})
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', {
list: [
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
],
}, '0'],
],
});
const nodes = await getAllFileNodesAcrossAccounts();
expect(nodes).toHaveLength(3);
const own = nodes.find((n) => n.id === 'own-1')!;
expect(own.isShared).toBe(false);
expect(isCrossAccountId(own.id)).toBe(false);
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
expect(folder.id).toBe('acc-2:s1');
expect(folder.isShared).toBe(true);
expect(folder.accountId).toBe('acc-2');
expect(folder.accountName).toBe('Team');
// parentId is namespaced the same way so child lookups keep working.
const child = nodes.find((n) => n.name === 'inside.txt')!;
expect(child.parentId).toBe('acc-2:s1');
});
it('skips personal accounts without the filenode capability', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-3': { name: 'other personal', isPersonal: true },
},
});
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', { list: [] }, '0']],
});
await getAllFileNodesAcrossAccounts();
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
});
});
describe('getFileNodeDownloadUrl', () => {
it('routes shared blobs to the owning account', () => {
const url = getFileNodeDownloadUrl({
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
blobId: 'b2', accountId: 'acc-2',
});
expect(url).toBe('https://dl/acc-2/b2');
});
});
describe('getPrincipals', () => {
it('queries then gets via back-reference', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Principal/query', { ids: ['p1'] }, '0'],
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
],
});
const principals = await getPrincipals();
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
const calls = mockRequest.mock.calls[0][0];
expect(calls[0][0]).toBe('Principal/query');
expect(calls[1][0]).toBe('Principal/get');
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
});
it('returns empty when the server lacks the principals capability', async () => {
mockHasCapability.mockReturnValue(false);
expect(await getPrincipals()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
+277
View File
@@ -0,0 +1,277 @@
// Stalwart account-security management. Mirrors the webmail's
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
// x:Account). The webmail proxies these through a server-side passthrough so
// the basic-auth header stays in an httpOnly cookie; the mobile client already
// holds its own credentials, so it calls the JMAP endpoint directly with the
// `urn:stalwart:jmap` capability in `using`.
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppCredentialInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export type AppPasswordInfo = AppCredentialInfo;
export type ApiKeyInfo = AppCredentialInfo;
export interface AppCredentialInput {
description: string;
expiresAt?: string | null;
allowedIps?: string[];
}
export interface AuthInfo {
otpEnabled: boolean;
appPasswords: AppPasswordInfo[];
apiKeys: ApiKeyInfo[];
}
export interface PrincipalInfo {
displayName: string;
emails: string[];
quota: number;
roles: string[];
}
type MethodResponse = [string, Record<string, any>, string];
// True once a live session advertises Stalwart's JMAP extension. Without it
// none of the x:* methods exist, so the screen shows a "not available" notice.
export function isStalwartSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return STALWART_CAPABILITY in (session.capabilities ?? {});
}
// Pull a single method response out by its call id and throw on a JMAP method
// error (the `['error', {type, description}, id]` envelope).
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
if (match[0] === 'error') {
const err = match[1] as { type?: string; description?: string };
throw new Error(err.description || err.type || 'JMAP error');
}
return match[1] as T;
}
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
const res = await jmapClient.request(methodCalls, STALWART_USING);
return res.methodResponses as MethodResponse[];
}
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
const allowedIps =
raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
if (!ips || ips.length === 0) return undefined;
return Object.fromEntries(ips.map((ip) => [ip, true]));
}
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
const body: Record<string, unknown> = { description: input.description };
if (input.expiresAt) body.expiresAt = input.expiresAt;
const allowed = ipsToMap(input.allowedIps);
if (allowed) body.allowedIps = allowed;
return body;
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
// ── Reads ─────────────────────────────────────────────────
export async function fetchAuthInfo(): Promise<AuthInfo> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
['x:AppPassword/query', { accountId }, 'aq'],
['x:ApiKey/query', { accountId }, 'kq'],
]);
const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p');
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
const followUps: [string, Record<string, unknown>, string][] = [];
if (appPwQuery.ids?.length) {
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
}
if (apiKeyQuery.ids?.length) {
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
let appPasswords: AppPasswordInfo[] = [];
let apiKeys: ApiKeyInfo[] = [];
if (followUps.length) {
const followUpResponses = await send(followUps);
if (appPwQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
appPasswords = (r.list ?? []).map(credentialFromResult);
}
if (apiKeyQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
apiKeys = (r.list ?? []).map(credentialFromResult);
}
}
return { otpEnabled, appPasswords, apiKeys };
}
export async function fetchEncryptionType(): Promise<EncryptionType> {
const accountId = jmapClient.accountId;
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
return extractEncryptionType(result.list?.[0]?.encryptionAtRest);
}
export async function fetchPrincipal(): Promise<PrincipalInfo> {
const accountId = jmapClient.accountId;
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
const result = resultFor<{
list?: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
}>;
}>(responses, '0');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
return {
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
};
}
// ── Mutations ─────────────────────────────────────────────
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
}
export async function updateDisplayName(displayName: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
}
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
}
export async function disableTotp(currentPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
}
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string; secret: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
return { id: created.id, secret: created.secret };
}
async function removeCredential(method: SetMethod, id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
}
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:AppPassword/set', input);
}
export function removeAppPassword(id: string): Promise<void> {
return removeCredential('x:AppPassword/set', id);
}
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:ApiKey/set', input);
}
export function removeApiKey(id: string): Promise<void> {
return removeCredential('x:ApiKey/set', id);
}
+4 -1
View File
@@ -116,12 +116,15 @@ export function getDownloadUrl(
blobId: string,
name?: string,
type?: string,
// Blobs of nodes shared by another principal live in the owner's account;
// downloading them with our own accountId 404s.
accountId?: string,
): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
+119 -15
View File
@@ -4,6 +4,39 @@ import type { Calendar, CalendarEvent } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* IANA time zone of the device, sent as the `timeZone` argument on
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
* filter values and computes utcStart/utcEnd for floating events in this zone,
* defaulting to UTC when absent — which shifts range boundaries and
* floating-event times for any user not in UTC. Stalwart ignores unparseable
* values, so sending it is always safe.
*/
function getUserTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
* calendar id per condition), not the draft's plural `inCalendars` array —
* sending the plural form fails the whole query with `unsupportedFilter`.
* Multiple calendars are expressed as an OR of singular conditions.
*/
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
if (calendarIds.length === 1) {
return { inCalendar: calendarIds[0] };
}
return {
operator: 'OR',
conditions: calendarIds.map((id) => ({ inCalendar: id })),
};
}
const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
@@ -26,43 +59,92 @@ function methodResult<T = any>(res: any, index = 0): T {
return entry[1] as T;
}
// Other session accounts that expose the calendars capability hold calendars
// shared with the user (JMAP surfaces shared data under the sharer's account).
function sharedCalendarAccountIds(): string[] {
const session = jmapClient.currentSession;
const primary = jmapClient.accountId;
return Object.entries(session?.accounts ?? {})
.filter(([id, info]) =>
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
.map(([id]) => id);
}
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
return methodResult<{ list: Calendar[] }>(res).list ?? [];
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
// Calendars shared with the user live in other session accounts. Failures
// there (revoked share, transient error) must not hide the user's own
// calendars, so each shared account is fetched best-effort.
const shared = await Promise.all(
sharedCalendarAccountIds().map(async (sharedAccountId) => {
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
// JMAP calendar ids are only unique within an account, so a shared
// "default" calendar collides with the user's own "default". Namespace
// the id (keeping the real id in originalId) so visibility toggles,
// event-query routing, and create/default mutations don't cross wires.
// Mirrors webmail's getAllCalendars.
return list.map((cal) => ({
...cal,
id: `${sharedAccountId}:${cal.id}`,
originalId: cal.id,
accountId: sharedAccountId,
isShared: true,
}));
} catch {
return [];
}
}),
);
return [...own, ...shared.flat()];
}
export async function queryEvents(
_calendarIds: string[],
calendarIds: string[],
_after: string,
_before: string,
accountId?: string,
): Promise<string[]> {
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
// Fetch all events in the account; calendar visibility + date filtering is
// done client-side.
const accountId = jmapClient.accountId;
// Stalwart rejects after/before filters on CalendarEvent/query; date
// filtering is done client-side. Calendars are restricted via the singular
// `inCalendar` condition (the plural `inCalendars` fails the whole query
// with unsupportedFilter on Stalwart).
const account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
const res = await jmapClient.request(
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
[['CalendarEvent/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
}
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
accountId: account,
ids: batch,
properties: CALENDAR_EVENT_PROPERTIES,
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
@@ -91,8 +173,9 @@ export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, {
create: {
@@ -117,9 +200,10 @@ export async function createEvent(
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
targetAccountId?: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
@@ -136,8 +220,9 @@ export async function updateEvent(
id: string,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
USING,
@@ -150,8 +235,9 @@ export async function updateEvent(
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
@@ -165,8 +251,9 @@ export async function rsvpEvent(
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
@@ -225,6 +312,23 @@ export async function createCalendar(
return created;
}
/**
* Mark a calendar as the account default. `isDefault` is read-only in
* Stalwart's Calendar/set — the default is changed via the
* `onSuccessSetIsDefault` request argument instead.
*/
export async function setDefaultCalendar(
calendarId: string,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
USING,
);
methodResult(res);
}
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string): Promise<void> {
+54 -3
View File
@@ -283,8 +283,10 @@ export async function getEmailChanges(
};
}
export async function getFullEmail(id: string): Promise<Email> {
const accountId = jmapClient.accountId;
export async function getFullEmail(id: string, accountIdOverride?: string): Promise<Email> {
// `accountIdOverride` lets the unified inbox open a message that lives under
// a group/shared account in the same session instead of the user's own.
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
@@ -321,6 +323,35 @@ export async function getFullEmails(ids: string[]): Promise<Email[]> {
return res.methodResponses[0][1].list as Email[];
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
* `importRawEmail` import step.
*/
export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
): Promise<string> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
emails: {
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
},
}, '0'],
]);
const result = res.methodResponses[0][1];
const notCreated = result?.notCreated?.['import-0'];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
}
const id = result?.created?.['import-0']?.id;
if (!id) throw new Error('Email import succeeded but no id was returned');
return id;
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
@@ -332,8 +363,9 @@ export async function getThread(threadId: string): Promise<Thread> {
export async function setEmailKeywords(
emailId: string,
keywords: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
]);
@@ -423,6 +455,25 @@ export async function restoreEmailMailboxes(
]);
}
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
// set, so this is idempotent — replaying it produces the same result no matter
// the current server state. The offline outbox relies on that to coalesce and
// safely retry move/archive/trash operations.
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay.
export async function destroyEmails(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
+220 -131
View File
@@ -1,188 +1,277 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
import type { FileNode, FileNodeRights, Principal } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
// A FileNode is a folder (container) only when it has no blob content — the
// server stores it with `file == null`. Sending a blobId/type/size on create
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
// nothing can ever be parented under (#379). The webmail migrates those
// legacy flat-named nodes into the real parentId hierarchy on load; this app
// reads and writes only the real hierarchy.
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
}
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
// e.g. a file inside the `Documents` folder is stored with
// `name = "Documents∕report.pdf"`. The display name shown to the user
// strips the prefix.
export const PATH_SEP = '∕';
// Stalwart omits shareWith/myRights from FileNode/get unless they are
// requested explicitly, so the share sheet and indicators must name them here.
const FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
'shareWith', 'myRights',
];
// Path is a list of folder display names from root. [] is root,
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
// `sub` folder under Documents.
export function pathToPrefix(path: readonly string[]): string {
if (path.length === 0) return '';
return path.join(PATH_SEP) + PATH_SEP;
export function supportsSharing(): boolean {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
}
// True when `node` is a direct child (not a descendant) of `prefix`.
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
if (!node.name) return false;
if (prefix === '') return !node.name.includes(PATH_SEP);
if (!node.name.startsWith(prefix)) return false;
const rest = node.name.slice(prefix.length);
return rest.length > 0 && !rest.includes(PATH_SEP);
function filesAccountId(): string {
return (
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
jmapClient.accountId
);
}
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
function fileUsing(): string[] {
const using: string[] = [CAPABILITIES.CORE];
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
using.push(CAPABILITIES.FILES);
}
// Required for shareWith/myRights on FileNode and for cross-account
// (shared-with-me) FileNode/get.
if (supportsSharing()) {
using.push(CAPABILITIES.PRINCIPALS_OWNER);
}
return using;
}
// Stalwart's FileNode/get does not support a parentId filter and some builds
// omit `type` from the default property set. We fetch the whole tree and
// filter client-side; properties are listed explicitly so `isDirectory()`
// always has a value to look at.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
// Nodes fetched from another principal's account are namespaced
// "accountId:nodeId" so they can't collide with the primary account's ids.
// JMAP ids never contain ':', so the separator unambiguously marks a shared
// (cross-account) node.
export function isCrossAccountId(id: string | null | undefined): boolean {
return id != null && id.includes(':');
}
// Fetch every FileNode in the account, files AND folders, to build the
// hierarchy client-side from parentId links.
//
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
// FileNode/query — Stalwart's query only returns leaf files and omits folder
// nodes entirely, which would make every folder invisible.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
fileUsing(),
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
const result = res.methodResponses[0];
if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode list failed');
}
return (result[1].list ?? []) as FileNode[];
}
export async function createFileNode(
fileNode: Partial<FileNode>,
): Promise<FileNode> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
create: { 'new-file': fileNode },
}, '0']],
USING,
);
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
// account that advertises the filenode capability or is non-personal, since
// Stalwart doesn't always advertise capabilities on shared accounts.
function filesCapableAccountIds(): string[] {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const rest = Object.entries(accounts)
.filter(([id, info]) =>
id !== primaryId &&
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
.map(([id]) => id);
return [primaryId, ...rest];
}
// Fetch every FileNode the logged-in user can see across all accessible
// accounts. Nodes owned by another principal (shared with the user) are
// tagged `isShared: true` with the owning accountId/accountName, and their
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
// listAllFileNodesAcrossAccounts.
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const all: FileNode[] = [];
for (const accountId of filesCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') continue;
for (const node of (result[1].list ?? []) as FileNode[]) {
all.push({
...node,
id: isPrimary ? node.id : `${accountId}:${node.id}`,
parentId: node.parentId == null
? null
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
accountId,
accountName: accounts[accountId]?.name || accountId,
isShared: !isPrimary,
});
}
} catch {
// A single unreachable shared account shouldn't hide the user's own
// files; skip it and keep aggregating.
}
}
return created;
return all;
}
export async function createFolder(
displayName: string,
currentPath: readonly string[],
name: string,
parentId: string | null,
): Promise<FileNode> {
// Stalwart's FileNode/set rejects directory creates without a blobId
// ("Missing blob id"), so we upload an empty blob first and reference it.
// Mirrors the webmail's createFileDirectory.
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
const accountId = filesAccountId();
const props: Record<string, unknown> = { name };
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-dir'];
if (!created) {
const err = result.notCreated?.['new-dir'];
throw new Error(err?.description || 'Create folder failed');
}
return { ...props, ...created } as FileNode;
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
fileUsing(),
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
// Rename a node and, if it's a folder, cascade the rename to every descendant
// so the encoded path stays consistent. The webmail does this same dance.
export async function renameFileNode(
node: FileNode,
newDisplayName: string,
currentPath: readonly string[],
allNodes: readonly FileNode[],
): Promise<void> {
const prefix = pathToPrefix(currentPath);
const oldServerName = node.name;
const newServerName = prefix + newDisplayName;
if (oldServerName === newServerName) return;
await updateFileNode(node.id, { name: newServerName });
if (isDirectory(node)) {
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const n of allNodes) {
if (n.id === node.id) continue;
if (n.name.startsWith(oldFolderPrefix)) {
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
}
}
}
export async function renameFileNode(id: string, newName: string): Promise<void> {
await updateFileNode(id, { name: newName });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
destroy: ids,
// The server removes descendant nodes of destroyed folders.
onDestroyRemoveChildren: true,
}, '0']],
fileUsing(),
);
}
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
// references, so we collect descendants by name-prefix and destroy them all
// in one call. Without this, deleting a folder leaves its files orphaned and
// still discoverable at the old encoded path.
export async function deleteWithDescendants(
targets: readonly FileNode[],
allNodes: readonly FileNode[],
): Promise<void> {
const ids = new Set<string>();
for (const node of targets) {
ids.add(node.id);
if (isDirectory(node)) {
const folderPrefix = node.name + PATH_SEP;
for (const n of allNodes) {
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
}
}
const notDestroyed = res.methodResponses[0][1].notDestroyed as
| Record<string, { description?: string }>
| undefined;
const failedIds = Object.keys(notDestroyed ?? {});
if (failedIds.length > 0) {
throw new Error(
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
);
}
await deleteFileNodes(Array.from(ids));
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
}
export async function uploadFileNode(
uri: string,
displayName: string,
name: string,
mimeType: string,
currentPath: readonly string[],
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
const type = blob.type || mimeType || 'application/octet-stream';
const props: Record<string, unknown> = {
name,
// Stalwart caps the stored MIME type; very long types fail the create.
type: type.length > 30 ? 'application/octet-stream' : type,
blobId: blob.blobId,
size: blob.size,
});
};
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-file'];
if (!created) {
const err = result.notCreated?.['new-file'];
throw new Error(err?.description || 'Upload failed');
}
return { ...props, ...created } as FileNode;
}
// ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or
// folder). Pass rights: null to revoke. Stalwart applies it via a
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
// its whole subtree — which is why this app must store real parentId
// hierarchy rather than the legacy flat-name encoding.
export async function setFileNodeShare(
fileNodeId: string,
principalId: string,
rights: FileNodeRights | null,
): Promise<void> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
if (result.notUpdated?.[fileNodeId]) {
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
}
if (!result.updated || !(fileNodeId in result.updated)) {
throw new Error('Server did not confirm the share update');
}
}
// List all principals visible to the user. Stalwart returns the full
// directory regardless of `filter`, so callers filter client-side.
export async function getPrincipals(): Promise<Principal[]> {
if (!supportsSharing()) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[
['Principal/query', { accountId }, '0'],
['Principal/get', {
accountId,
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
return (getResp?.[1].list ?? []) as Principal[];
}
// The principal id that represents the logged-in user (excluded from the
// share picker — sharing with yourself is a no-op the server may reject).
export function ownPrincipalId(): string {
return filesAccountId();
}
+8
View File
@@ -60,6 +60,13 @@ export class JMAPClient {
return this.credentials?.serverUrl ?? null;
}
// True when the session authenticates with a Bearer token (OAuth handoff or
// token login) rather than a username/password. Used by the security screen
// to hide password/TOTP management, which only applies to password accounts.
get usesBearerAuth(): boolean {
return !!this.credentials?.accessToken;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
@@ -144,6 +151,7 @@ export class JMAPClient {
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
if (this.credentials.accessToken === next.accessToken) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
+194
View File
@@ -0,0 +1,194 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { secureFetch } from '../lib/client-cert';
import type { SieveScript, SieveCapabilities } from '../lib/sieve/types';
// JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods
// but follows the mobile convention of a functional api/* module driving the
// shared `jmapClient` singleton (see api/vacation.ts, api/blob.ts).
const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE];
function requireSession() {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session;
}
// Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account
// when the server does not advertise a dedicated one (Stalwart uses the same id).
export function getSieveAccountId(): string {
const session = jmapClient.currentSession;
return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId;
}
export function isSieveSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return CAPABILITIES.SIEVE in (session.capabilities ?? {});
}
export function getSieveCapabilities(): SieveCapabilities | null {
const session = jmapClient.currentSession;
if (!session) return null;
const info = session.accounts?.[getSieveAccountId()];
const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE];
return (caps as SieveCapabilities) ?? null;
}
export async function getSieveScripts(): Promise<SieveScript[]> {
const res = await jmapClient.request(
[['SieveScript/get', { accountId: getSieveAccountId() }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/get') {
return ((resp[1] as { list?: SieveScript[] }).list ?? []) as SieveScript[];
}
throw new Error('Failed to fetch Sieve scripts');
}
export async function getSieveScriptContent(blobId: string): Promise<string> {
const session = requireSession();
const url = session.downloadUrl
.replace('{accountId}', encodeURIComponent(getSieveAccountId()))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent('script.sieve'))
.replace('{type}', encodeURIComponent('application/sieve'));
const response = await secureFetch(url, {
headers: { Authorization: jmapClient.authHeader },
});
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
return response.text();
}
async function uploadSieveBlob(content: string): Promise<string> {
const session = requireSession();
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(getSieveAccountId()),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/sieve',
Authorization: jmapClient.authHeader,
},
// A plain string body is encoded as UTF-8 by RN's fetch. (api/blob.ts uses
// an ArrayBuffer only because typed-array bodies get stringified there.)
body: content,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Failed to upload Sieve script: ${response.status}${detail ? ` ${detail.substring(0, 200)}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string };
if (typeof direct.blobId === 'string') return direct.blobId;
const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined;
if (nested?.blobId) return nested.blobId;
throw new Error('Upload succeeded but response did not include a blobId');
}
export async function createSieveScript(
name: string,
content: string,
activate = true,
): Promise<SieveScript> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
create: { 'new-script': { name, blobId } },
};
if (activate) setArgs.onSuccessActivateScript = '#new-script';
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as {
notCreated?: Record<string, { description?: string }>;
created?: Record<string, { id?: string }>;
};
if (result.notCreated?.['new-script']) {
throw new Error(result.notCreated['new-script'].description ?? 'Failed to create Sieve script');
}
const createdId = result.created?.['new-script']?.id;
if (createdId) {
const scripts = await getSieveScripts();
const script = scripts.find((s) => s.id === createdId);
if (script) return script;
}
}
throw new Error('Failed to create Sieve script');
}
export async function updateSieveScript(
scriptId: string,
content: string,
activate = true,
): Promise<void> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
update: { [scriptId]: { blobId } },
};
if (activate) setArgs.onSuccessActivateScript = scriptId;
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.[scriptId]) {
throw new Error(result.notUpdated[scriptId].description ?? 'Failed to update Sieve script');
}
return;
}
throw new Error('Failed to update Sieve script');
}
export async function deleteSieveScript(scriptId: string): Promise<void> {
const res = await jmapClient.request(
[['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notDestroyed?: Record<string, { description?: string }> };
if (result.notDestroyed?.[scriptId]) {
throw new Error(result.notDestroyed[scriptId].description ?? 'Failed to delete Sieve script');
}
return;
}
throw new Error('Failed to delete Sieve script');
}
export async function validateSieveScript(
content: string,
): Promise<{ isValid: boolean; errors?: string[] }> {
const blobId = await uploadSieveBlob(content);
const res = await jmapClient.request(
[['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/validate') {
const result = resp[1] as { error?: { description?: string } };
if (result.error) {
return { isValid: false, errors: [result.error.description ?? 'Validation failed'] };
}
return { isValid: true };
}
if (resp && typeof resp[0] === 'string' && resp[0].endsWith('error')) {
const error = resp[1] as { description?: string };
return { isValid: false, errors: [error.description ?? 'Validation failed'] };
}
return { isValid: false, errors: ['Unexpected validation response'] };
}
+66
View File
@@ -19,6 +19,9 @@ export interface JMAPAccountInfo {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
// Per-account capability objects (RFC 8620 §2). Optional because not every
// server populates it; the Sieve panel reads its limits/extensions from here.
accountCapabilities?: Record<string, unknown>;
}
export type JMAPMethodCall = [string, Record<string, unknown>, string];
@@ -402,6 +405,7 @@ export interface VirtualLocation {
}
export interface RecurrenceRule {
'@type'?: 'RecurrenceRule';
frequency: string;
interval?: number;
until?: string;
@@ -416,6 +420,8 @@ export interface RecurrenceRule {
bySecond?: number[];
bySetPosition?: number[];
firstDayOfWeek?: string;
rscale?: string;
skip?: string;
}
export interface Alert {
@@ -449,6 +455,9 @@ export interface CalendarEvent {
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events.
originalCalendarIds?: Record<string, boolean>;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
@@ -464,6 +473,12 @@ export interface CalendarEvent {
color?: string;
// Client-only: account the event/occurrence belongs to (for shared/virtual).
localAccountId?: string;
// Client-only: JMAP account the event was fetched from. Absent for the
// primary account; set for events on calendars shared with the user so
// mutations can be routed to the owning account.
accountId?: string;
// Client-only: true when the event lives on a calendar shared with the user.
isShared?: boolean;
}
export interface CalendarRights {
@@ -489,6 +504,21 @@ export interface Calendar {
isDefault?: boolean;
sortOrder?: number;
myRights?: CalendarRights;
// Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user.
accountId?: string;
// Client-only: the real JMAP id of a shared calendar, before its `id` was
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
// and event queries must use this raw id against the owning account.
originalId?: string;
// Client-only: true when the calendar lives in another account (shared
// with the user by its owner).
isShared?: boolean;
// Client-only: set when `color` has been replaced by the viewer's local
// override for a shared calendar (see lib/calendar-utils). When true, the
// override wins over per-event colors so the whole shared calendar paints
// uniformly.
colorIsLocalOverride?: boolean;
}
// ─── Files (FileNode) ───────────────────────────────────
@@ -502,6 +532,40 @@ export interface FileNode {
size?: number;
created?: string;
updated?: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// principals capability and the properties are requested explicitly. A node
// is shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user ("Shared with me").
isShared?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across accounts so blob downloads route to the right account.
accountId?: string;
accountName?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
// RFC 9670 principal — an entity (user, group, resource) that files and
// calendars can be shared with. On Stalwart the principal id doubles as the
// account id of that user/group.
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
}
// ─── Push / SSE ─────────────────────────────────────────
@@ -535,4 +599,6 @@ export const CAPABILITIES = {
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
PRINCIPALS: 'urn:ietf:params:jmap:principals',
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
} as const;
+88 -27
View File
@@ -20,6 +20,17 @@ const TOKEN_REFRESH_LEEWAY_MS = 60_000;
export interface UnifiedEmail extends Email {
/** Registry account id this message belongs to (not the JMAP account id). */
sourceAccountId: string;
/**
* JMAP account id the message lives under. Equals the registry account's
* primary JMAP account for own mail, or a group/shared owner account when
* the message came from a group inbox. Carried so opening can target the
* right account via an Email/get override.
*/
jmapAccountId: string;
/** True when this message belongs to a group/shared inbox, not the user's own. */
isShared: boolean;
/** Human label for the owning account when shared (e.g. "Support"). */
sharedLabel?: string;
}
export interface UnifiedInboxResult {
@@ -90,7 +101,10 @@ async function ensureFreshCredentials(
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
await jmapClient.setStoredCredentials(accountId, updated);
const current = await jmapClient.getStoredCredentials(accountId);
if (!current || current.accessToken !== updated.accessToken) {
await jmapClient.setStoredCredentials(accountId, updated);
}
return updated;
} catch {
// Fall back to the existing (possibly expired) token; the request will
@@ -116,29 +130,15 @@ async function jmapPost(
return body.methodResponses ?? [];
}
async function fetchInboxForAccount(
accountId: string,
// Fetch the most recent inbox messages for a single JMAP account reachable via
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
// them with registry/sharing metadata.
async function fetchInboxEmailsForJmapAccount(
apiUrl: string,
authHeader: string,
jmapAccountId: string,
limit: number,
): Promise<UnifiedEmail[]> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (sessionRes.status === 401) throw new Error('Session expired');
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteApiUrl(session, baseUrl);
const jmapAccountId = resolveJmapAccountId(session);
if (!jmapAccountId) throw new Error('No mail account in session');
// Find the inbox mailbox.
): Promise<Email[]> {
const mailboxResponses = await jmapPost(
apiUrl,
authHeader,
@@ -149,7 +149,6 @@ async function fetchInboxForAccount(
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
if (!inbox) return [];
// Query + fetch the most recent messages.
const queryResponses = await jmapPost(
apiUrl,
authHeader,
@@ -170,19 +169,81 @@ async function fetchInboxForAccount(
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
}
async function fetchInboxForAccount(
accountId: string,
limit: number,
includeGroup: boolean,
): Promise<UnifiedEmail[]> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (sessionRes.status === 401) throw new Error('Session expired');
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteApiUrl(session, baseUrl);
const primaryJmapId = resolveJmapAccountId(session);
if (!primaryJmapId) throw new Error('No mail account in session');
// Always include the user's own primary inbox. When group inboxes are on,
// also scan every other (non-personal / shared) account reachable in the
// session — these are the group/shared mailboxes the user belongs to.
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
{ jmapId: primaryJmapId, isShared: false },
];
if (includeGroup) {
for (const [id, info] of Object.entries(session.accounts ?? {})) {
if (id === primaryJmapId) continue;
// Only accounts that expose the Mail capability can have an inbox.
const hasMail =
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
if (!hasMail) continue;
targets.push({ jmapId: id, isShared: true, label: info.name });
}
}
const perTarget = await Promise.all(
targets.map(async (target) => {
try {
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
return list.map<UnifiedEmail>((e) => ({
...e,
sourceAccountId: accountId,
jmapAccountId: target.jmapId,
isShared: target.isShared,
sharedLabel: target.isShared ? target.label : undefined,
}));
} catch {
// A single inaccessible shared account shouldn't sink the whole
// account's inbox; skip it and keep the others.
return [] as UnifiedEmail[];
}
}),
);
return perTarget.flat();
}
export async function fetchUnifiedInbox(
accountIds: string[],
perAccountLimit = 25,
opts: { includeGroup?: boolean } = {},
): Promise<UnifiedInboxResult> {
const { includeGroup = false } = opts;
const errors: Record<string, string> = {};
const settled = await Promise.all(
accountIds.map(async (id) => {
try {
return await fetchInboxForAccount(id, perAccountLimit);
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
} catch (err) {
errors[id] = err instanceof Error ? err.message : 'Failed to load';
return [] as UnifiedEmail[];
+371 -7
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
@@ -27,6 +27,15 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
interface EmailBodyViewProps {
email: Email;
senderEmail?: string;
// The body is a native WebView, which on Android swallows horizontal touches
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
onSwipe?: (direction: 'prev' | 'next') => void;
// Pinch-zoom state from inside the page: `pinching` while two fingers are
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
// scroll the pane away or switch mails.
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
function extractHtmlBody(email: Email): string | null {
@@ -45,29 +54,188 @@ function extractTextBody(email: Email): string | null {
return null;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children.
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
el.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
const HEIGHT_REPORTER = `
(function () {
// One-time: wrap body contents in a block-level div we can measure reliably.
// body.scrollHeight on Android WebView is pinned to the container viewport
// height, so we can't trust it. A dedicated wrapper gives a deterministic
// bounding box.
var wrapperEl = null;
function ensureWrapper() {
var b = document.body;
if (!b) return null;
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
var wrap = document.getElementById('__rn_email_body_wrap__');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = '__rn_email_body_wrap__';
wrap.style.display = 'block';
wrap.style.width = '100%';
wrap.style.transformOrigin = 'top left';
while (b.firstChild) wrap.appendChild(b.firstChild);
b.appendChild(wrap);
}
wrapperEl = wrap;
return wrap;
}
// The width available for the email content (body content-box).
function availWidth() {
var b = document.body;
var cs = window.getComputedStyle(b);
var padL = parseFloat(cs.paddingLeft) || 0;
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
// Shared zoom state, mutated by the pinch-zoom handler injected after this
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
// on top of it, "tx" pans the zoomed overflow into view horizontally.
// Vertical panning needs no state here: the height reports below carry the
// scaled height, so the surrounding native ScrollView grows and scrolls.
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
// Apply the combined fit × pinch transform using the cached layout numbers
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
function applyTransform(w) {
var s = Z.fit * Z.scale;
if (Z.scale > 1.001 || Z.fit < 0.999) {
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
if (Z.tx < minTx) Z.tx = minTx;
if (Z.tx > 0) Z.tx = 0;
w.style.width = Z.layoutW + 'px';
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
} else {
Z.tx = 0;
w.style.width = '100%';
w.style.transform = 'none';
}
}
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
// the screen — the way native mail clients do. No sideways scrolling.
function fitToWidth(w) {
if (!w) return;
var avail = availWidth();
if (avail <= 0) return;
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
Z.avail = avail;
Z.layoutW = content > avail + 1 ? content : avail;
Z.fit = avail / Z.layoutW;
applyTransform(w);
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
// While a pinch is in flight only re-apply the transform — a full re-fit
// resets the wrapper width and would thrash layout on every frame.
if (Z.pinching) applyTransform(w);
else fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
var b = document.body;
var cs = window.getComputedStyle(b);
@@ -117,7 +285,16 @@ const HEIGHT_REPORTER = `
if (document.body) ro.observe(document.body);
} catch (e) {}
try {
var mo = new MutationObserver(report);
var mo = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
// otherwise scaling would re-trigger a measure and loop indefinitely.
var rec = records[i];
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
report();
return;
}
});
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
} catch (e) {}
try {
@@ -126,11 +303,161 @@ const HEIGHT_REPORTER = `
img.addEventListener('error', report);
});
} catch (e) {}
// Hooks for the pinch-zoom handler script.
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
window.__rnReport = report;
true;
})();
`;
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
// Detect a clear horizontal flick inside the WebView and report it. Uses
// capture-phase, passive listeners so it never blocks vertical scrolling, text
// selection, or link taps — it only reacts to a quick, horizontal-dominant
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
const SWIPE_DETECTOR = `
(function () {
if (window.__rnSwipeInit) return;
window.__rnSwipeInit = true;
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
document.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
var t = e.touches[0];
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
}, { passive: true, capture: true });
document.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
// content — never switch mails.
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
var t = e.changedTouches[0];
if (!t) return;
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
}
}
}, { passive: true, capture: true });
true;
})();
`;
// Pinch zoom + pan for the email content, driven entirely in the page: native
// WebView zoom can't work here because the WebView is auto-height inside a
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
// scales the measured wrapper (on top of the shrink-to-fit scale the height
// reporter maintains) anchored at the fingers' horizontal midpoint; while
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
// still scroll the surrounding ScrollView, which sees the scaled height
// through the regular height reports. Pinch moves call preventDefault so the
// engine consumes the gesture and Android parents don't intercept it; `zoom`
// messages let the RN side freeze the pane scroll / pager as a second guard.
const PINCH_ZOOM = `
(function () {
if (window.__rnPinchInit) return;
window.__rnPinchInit = true;
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
var MAX_ZOOM = 4;
var pinch = null; // { d0, s0, cx0, tx0 }
var pan = null; // { x0, tx0 }
var rafPending = false;
function post(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function apply() {
if (window.__rnApplyZoom) window.__rnApplyZoom();
}
// Height reports are rAF-throttled during the gesture so the RN container
// keeps up with the growing content without re-measuring on every move.
function scheduleReport() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
if (window.__rnReport) window.__rnReport();
});
}
function dist(t0, t1) {
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
var t0 = e.touches[0], t1 = e.touches[1];
pinch = {
d0: dist(t0, t1) || 1,
s0: Z.scale,
cx0: (t0.clientX + t1.clientX) / 2,
tx0: Z.tx,
};
pan = null;
if (!Z.pinching) {
Z.pinching = true;
post({ type: 'zoom', pinching: true, scale: Z.scale });
}
e.preventDefault();
} else if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
} else {
pan = null;
}
}, { passive: false, capture: true });
document.addEventListener('touchmove', function (e) {
if (pinch && e.touches.length === 2) {
e.preventDefault();
var t0 = e.touches[0], t1 = e.touches[1];
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
if (z < 1) z = 1;
if (z > MAX_ZOOM) z = MAX_ZOOM;
// Keep the content under the fingers' midpoint stationary horizontally:
// the wrapper-local point that started under cx0 must map back to the
// current midpoint at the new scale. (applyTransform clamps the result.)
var cx = (t0.clientX + t1.clientX) / 2;
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
Z.scale = z;
Z.tx = cx - local * (Z.fit * z);
apply();
scheduleReport();
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
// No preventDefault: vertical-dominant drags must keep scrolling the
// native ScrollView around the WebView. Horizontal-dominant drags stay
// inside the WebView and pan here.
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
apply();
}
}, { passive: false, capture: true });
function onTouchEnd(e) {
if (pinch && e.touches.length < 2) {
pinch = null;
// Snap back to the fitted layout when the pinch ends near scale 1.
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
Z.pinching = false;
apply();
if (window.__rnReport) window.__rnReport();
post({ type: 'zoom', pinching: false, scale: Z.scale });
// A finger left on screen continues as a pan.
if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
}
}
if (e.touches.length === 0) pan = null;
}
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
true;
})();
`;
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
@@ -273,8 +600,36 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
// Inversion is applied for HTML bodies in dark mode unless the email ships its
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
// stylesheet backgrounds) so it runs before the height reporter measures.
const applyInversion =
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
const injectedJs = React.useMemo(
() =>
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
+ SWIPE_DETECTOR + PINCH_ZOOM,
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
const data = e.nativeEvent.data;
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
const msg = JSON.parse(data);
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
} else if (msg.type === 'zoom') {
onZoomChange?.({
pinching: !!msg.pinching,
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
});
}
} catch { /* ignore malformed */ }
return;
}
const parsed = parseInt(data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
};
@@ -316,16 +671,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout.
// `injectedJavaScript` runs too early to see the final layout. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
// again here is a no-op once the body has been processed.
setTimeout(() => {
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
webviewRef.current?.injectJavaScript(injectedJs);
}, 50);
}}
scrollEnabled={false}
// Native Android zoom would scale the viewport inside the fixed-size
// container; pinch zoom is implemented in the page instead.
setBuiltInZoomControls={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
+205
View File
@@ -0,0 +1,205 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
interface IdentitySheetProps {
visible: boolean;
onClose: () => void;
identities: Identity[];
selectedIdentityId: string | null;
onPick: (identity: Identity) => void;
}
export function IdentitySheet({
visible, onClose, identities, selectedIdentityId, onPick,
}: IdentitySheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollList}>
{identities.map((identity) => {
const isSelected = identity.id === selectedIdentityId;
return (
<Pressable
key={identity.id}
onPress={() => {
onPick(identity);
onClose();
}}
style={({ pressed }) => [
styles.identityRow,
pressed && styles.identityRowPressed,
]}
>
<View style={styles.iconWrap}>
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
</View>
<View style={styles.identityDetails}>
{!!identity.name && (
<Text style={styles.identityName} numberOfLines={1}>
{identity.name}
</Text>
)}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email}
</Text>
</View>
{isSelected && (
<View style={styles.checkWrap}>
<Check size={16} color={c.primary} />
</View>
)}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '60%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
scrollList: {
paddingVertical: spacing.xs,
},
identityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 52,
},
identityRowPressed: {
backgroundColor: c.surfaceHover,
},
iconWrap: {
marginRight: spacing.md,
width: 28,
height: 28,
borderRadius: radius.xs,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
identityDetails: {
flex: 1,
justifyContent: 'center',
},
identityName: {
...typography.bodyMedium,
color: c.text,
},
identityEmail: {
...typography.caption,
color: c.textSecondary,
marginTop: 2,
},
identityEmailOnly: {
...typography.bodyMedium,
color: c.text,
marginTop: 0,
},
checkWrap: {
marginLeft: spacing.md,
},
});
}
+8 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -14,12 +15,18 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
if (online) return null;
const queuedHint = queued > 0
? `${queued} change${queued === 1 ? '' : 's'} will sync when you reconnect`
: null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
return (
<View style={styles.bar}>
<CloudOff size={14} color={c.primaryForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{hint ? ` — ${hint}` : ''}
You are offline{suffix ? ` — ${suffix}` : ''}
</Text>
</View>
);
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
// Fires once with the raw decoded QR string. The parent is responsible for
// closing the modal and interpreting the payload.
onScanned: (data: string) => void;
}
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
React.useEffect(() => {
if (visible) handled.current = false;
}, [visible]);
const handleBarcode = React.useCallback(
(result: BarcodeScanningResult) => {
if (handled.current) return;
handled.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
</View>
) : (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
</Button>
</View>
)}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: '#ffffff' },
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: '#ffffff',
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hint: {
...typography.caption,
color: '#ffffff',
textAlign: 'center',
paddingHorizontal: spacing.xxl,
},
permissionWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
paddingHorizontal: spacing.xxl,
backgroundColor: c.background,
},
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+3 -273
View File
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
const MIN_HEIGHT = 220;
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
@@ -66,273 +63,6 @@ interface Props {
onBlur?: () => void;
}
function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
@@ -340,7 +70,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_HEIGHT);
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
@@ -400,7 +130,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
break;
}
case 'selection':
+2 -2
View File
@@ -172,9 +172,9 @@ export function SwipeableRow({
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+1
View File
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
flexDirection: 'row',
+32 -26
View File
@@ -1,15 +1,16 @@
import React from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native';
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
import { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
dayKey,
eventsOnDayFromIndex,
getEventStartDate,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { EventCard } from './EventCard';
@@ -19,6 +20,7 @@ interface AgendaViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
}
@@ -40,6 +42,7 @@ export function AgendaView({
events,
calendars,
eventsByDay,
timeFormat,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
@@ -50,32 +53,35 @@ export function AgendaView({
);
const sections = React.useMemo<DaySection[]>(() => {
const result: DaySection[] = [];
const start = new Date(fromDate);
start.setHours(0, 0, 0, 0);
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
// every day that actually has events, always include a "Today" anchor, and
// render them sorted ascending. Driving the list off the events (rather than
// a fixed day-counter) guarantees every upcoming loaded event appears - not
// just the ones inside a brittle N-day window from the anchor.
const anchor = startOfDay(fromDate);
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
for (let i = 0; i < daysAhead; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) - mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
}
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
if (diff !== 0) return diff;
return (a.title || '').localeCompare(b.title || '');
});
if (dayEvents.length === 0 && !isToday(day)) continue;
result.push({
const days = new Map<string, Date>();
for (const key of index.keys()) {
const day = parseISO(key); // index keys are local yyyy-MM-dd
if (isNaN(day.getTime())) continue;
if (day < anchor || day >= horizonEnd) continue;
days.set(key, day);
}
// Always anchor "Today" so the view has a stable starting point even when
// the current day has no events (webmail injects the same anchor).
const today = startOfDay(new Date());
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
return [...days.values()]
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => ({
title: formatDayHeader(day),
date: day,
data: dayEvents,
});
}
return result;
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
// start, then title), so no extra sort is needed here.
data: eventsOnDayFromIndex(index, day),
}));
}, [fromDate, daysAhead, index]);
if (sections.length === 0) {
@@ -107,7 +113,7 @@ export function AgendaView({
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
</View>
)}
renderSectionFooter={({ section }) =>
+159 -37
View File
@@ -11,12 +11,13 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X, Upload, Rss } from 'lucide-react-native';
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { getCalendarColor } from '../../lib/calendar-utils';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
interface CalendarSidebarDrawerProps {
visible: boolean;
@@ -26,6 +27,11 @@ interface CalendarSidebarDrawerProps {
onClose: () => void;
onImport?: () => void;
onManageSubscriptions?: () => void;
// Long-press actions. Set-default applies to the user's own calendars;
// color change/reset applies to shared calendars (per-viewer recolor).
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}
export function CalendarSidebarDrawer({
@@ -36,6 +42,9 @@ export function CalendarSidebarDrawer({
onClose,
onImport,
onManageSubscriptions,
onSetDefault,
onSetColor,
onResetColor,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -43,6 +52,7 @@ export function CalendarSidebarDrawer({
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
const [expandedId, setExpandedId] = React.useState<string | null>(null);
React.useEffect(() => {
if (visible) {
@@ -60,6 +70,7 @@ export function CalendarSidebarDrawer({
}),
]).start();
} else {
setExpandedId(null);
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
@@ -78,8 +89,23 @@ export function CalendarSidebarDrawer({
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false);
const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false);
const sharedCalendars = calendars.filter((cal) => cal.isShared);
const myCalendars = calendars.filter(
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
);
const subscribed = calendars.filter(
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
);
const sectionProps = {
hiddenSet,
onToggle,
expandedId,
onExpand: setExpandedId,
onSetDefault,
onSetColor,
onResetColor,
};
return (
<Modal
@@ -110,21 +136,15 @@ export function CalendarSidebarDrawer({
)}
{myCalendars.length > 0 && (
<Section
title="My calendars"
calendars={myCalendars}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
)}
{sharedCalendars.length > 0 && (
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} />
)}
{subscribed.length > 0 && (
<Section
title="Subscribed"
calendars={subscribed}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
)}
{(onImport || onManageSubscriptions) && (
@@ -161,11 +181,21 @@ function Section({
calendars,
hiddenSet,
onToggle,
expandedId,
onExpand,
onSetDefault,
onSetColor,
onResetColor,
}: {
title: string;
calendars: Calendar[];
hiddenSet: Set<string>;
onToggle: (id: string) => void;
expandedId: string | null;
onExpand: (id: string | null) => void;
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -174,30 +204,85 @@ function Section({
<Text style={styles.sectionTitle}>{title}</Text>
{calendars.map((cal) => {
const visible = !hiddenSet.has(cal.id);
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
const canRecolor = !!onSetColor && !!cal.isShared;
const hasActions = canSetDefault || canRecolor;
const expanded = expandedId === cal.id && hasActions;
return (
<Pressable
key={cal.id}
onPress={() => onToggle(cal.id)}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
<View key={cal.id}>
<Pressable
onPress={() => onToggle(cal.id)}
onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
{cal.name}
</Text>
</Pressable>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
]}
>
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]} numberOfLines={1}>
{cal.name}
</Text>
{cal.isDefault && !cal.isShared && (
<Star size={14} color={c.textMuted} fill={c.textMuted} />
)}
</Pressable>
{expanded && (
<View style={styles.actionsPanel}>
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Set as default calendar</Text>
</Pressable>
)}
{canRecolor && (
<>
<View style={styles.paletteRow}>
{CALENDAR_COLOR_PALETTE.map((color) => {
const active = cal.color?.toLowerCase() === color.toLowerCase();
return (
<Pressable
key={color}
onPress={() => { onExpand(null); onSetColor!(cal, color); }}
style={[
styles.paletteSwatch,
{ backgroundColor: color },
active && styles.paletteSwatchActive,
]}
>
{active && <Check size={12} color={c.textInverse} />}
</Pressable>
);
})}
</View>
{!!onResetColor && cal.colorIsLocalOverride && (
<Pressable
onPress={() => { onExpand(null); onResetColor(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Shuffle size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Random color</Text>
</Pressable>
)}
</>
)}
</View>
)}
</View>
);
})}
</View>
@@ -279,6 +364,43 @@ function makeStyles(c: ThemePalette) {
rowName: { flex: 1, ...typography.body, color: c.text },
rowNameMuted: { color: c.textMuted },
actionsPanel: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
overflow: 'hidden',
},
panelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minHeight: 40,
},
panelRowText: { ...typography.body, color: c.text },
paletteRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
paletteSwatch: {
width: 24,
height: 24,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
paletteSwatchActive: {
borderWidth: 2,
borderColor: c.text,
},
actionsSection: {
marginTop: spacing.lg,
paddingTop: spacing.sm,
+14 -6
View File
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
import {
eventTimeRange,
getEventColor,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
}
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
function formatTimeRange(event: CalendarEvent): string {
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) return 'All day';
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
const fmt = timePattern(timeFormat);
return `${format(start, fmt)} – ${format(end, fmt)}`;
}
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const color = getEventColor(event, calendars);
const time = formatTimeRange(event);
const time = formatTimeRange(event, timeFormat);
const count = participantCount(event);
return (
@@ -79,7 +86,8 @@ function makeStyles(c: ThemePalette) {
card: {
flexDirection: 'row',
backgroundColor: c.card,
borderRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderBottomRightRadius: radius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
borderWidth: 1,
+9 -4
View File
@@ -36,6 +36,8 @@ import {
eventTimeRange,
getEventColor,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
timeFormat?: TimeFormat;
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) {
if (
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
const t = timePattern(timeFormat);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
}
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
}
function recurrenceLabel(event: CalendarEvent): string | null {
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
export function EventDetailSheet({
event,
calendars,
timeFormat,
onClose,
onEdit,
onDelete,
@@ -156,7 +161,7 @@ export function EventDetailSheet({
const color = getEventColor(event, calendars);
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const range = formatRange(event, timeFormat);
const recurrence = recurrenceLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
+69 -11
View File
@@ -31,6 +31,7 @@ import type {
CalendarEvent,
Participant,
EventLocation,
RecurrenceRule,
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -49,10 +50,12 @@ import {
REMINDER_PRESETS,
type Reminder,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { RecurrenceEditor } from './RecurrenceEditor';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'none', label: 'Does not repeat' },
@@ -60,6 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
{ value: 'custom', label: 'Custom…' },
];
interface EventModalProps {
@@ -99,11 +103,15 @@ function buildDuration(start: Date, end: Date): string {
function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption {
const rule = event?.recurrenceRules?.[0];
if (!rule) return 'none';
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
if (isSimpleRecurrenceRule(rule)) {
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
}
}
return 'none';
// Anything the simple presets can't represent is edited via the custom
// recurrence editor.
return 'custom';
}
function detectLocation(event: CalendarEvent | null | undefined): string {
@@ -156,9 +164,18 @@ export function EventModal({
const [allDay, setAllDay] = React.useState(false);
const [start, setStart] = React.useState<Date>(() => nextHalfHour(defaultDate ?? new Date()));
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
// New events land in the explicitly requested calendar, else the account's
// default calendar, else the first one.
const fallbackCalendarId =
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '';
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]);
const [location, setLocation] = React.useState('');
const [videoUrl, setVideoUrl] = React.useState('');
@@ -181,7 +198,10 @@ export function EventModal({
setEnd(getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setRecurrence(detectRecurrence(event));
const detected = detectRecurrence(event);
setRecurrence(detected);
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts));
setLocation(detectLocation(event));
setVideoUrl(detectVideoUrl(event));
@@ -192,9 +212,16 @@ export function EventModal({
setAllDay(false);
setStart(d);
setEnd(addHours(d, 1));
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
setCalendarId(
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '',
);
setParticipants({});
setRecurrence('none');
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setLocation('');
setVideoUrl('');
@@ -241,7 +268,12 @@ export function EventModal({
: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
participants: Object.keys(participants).length > 0 ? participants : undefined,
recurrenceRules: recurrenceFromOption(recurrence),
recurrenceRules:
recurrence === 'custom'
? customRule
? [customRule]
: undefined
: recurrenceFromOption(recurrence),
alerts: remindersToAlerts(reminders),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location),
@@ -259,7 +291,10 @@ export function EventModal({
};
const selectedCalendar = calendars.find((c) => c.id === calendarId);
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const recurrenceLabel =
recurrence === 'custom'
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const addReminder = (minutesBefore: number) => {
setReminderPickerOpen(false);
@@ -431,8 +466,14 @@ export function EventModal({
opt.value === recurrence && styles.popoverRowActive,
]}
onPress={() => {
setRecurrence(opt.value);
setRecurrenceOpen(false);
if (opt.value === 'custom') {
setRecurrenceEditorOpen(true);
return;
}
setRecurrence(opt.value);
setCustomRule(null);
setRecurrenceEditorOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
@@ -440,6 +481,23 @@ export function EventModal({
))}
</View>
)}
{recurrenceEditorOpen && (
<RecurrenceEditor
rule={customRule}
eventStart={start}
onSave={(rule) => {
setCustomRule(rule);
setRecurrence('custom');
setRecurrenceEditorOpen(false);
}}
onCancel={() => setRecurrenceEditorOpen(false)}
/>
)}
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
<Text style={styles.addRowText}>Edit custom recurrence</Text>
</Pressable>
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
@@ -0,0 +1,370 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { ChevronDown } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '..';
import {
EDITOR_FREQUENCIES,
UNIT_LABELS,
WEEKDAYS,
buildRecurrenceSummary,
buildRuleFromEditorValue,
capitalize,
editorValueFromRule,
monthName,
nthLabel,
weekdayName,
type EditorFrequency,
type EndsMode,
type MonthlyMode,
type RecurrenceEditorValue,
} from '../../lib/recurrence';
interface RecurrenceEditorProps {
rule: RecurrenceRule | null;
eventStart: Date;
onSave: (rule: RecurrenceRule) => void;
onCancel: () => void;
}
// Small popover-style select, matching the EventModal field pattern.
function InlineSelect<T extends string | number>({
value,
options,
onChange,
flex,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
flex?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [open, setOpen] = React.useState(false);
const selected = options.find((o) => o.value === value);
return (
<View style={flex ? styles.selectFlex : undefined}>
<Pressable style={styles.selectButton} onPress={() => setOpen((v) => !v)}>
<Text style={styles.selectText} numberOfLines={1}>
{selected?.label ?? String(value)}
</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
{open && (
<View style={styles.popover}>
{options.map((opt) => (
<Pressable
key={String(opt.value)}
style={[styles.popoverRow, opt.value === value && styles.popoverRowActive]}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
</Pressable>
))}
</View>
)}
</View>
);
}
function NumberInput({
value,
min,
max,
onChange,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [text, setText] = React.useState(String(value));
React.useEffect(() => setText(String(value)), [value]);
return (
<TextInput
value={text}
onChangeText={setText}
onEndEditing={() => {
const n = parseInt(text, 10);
const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
setText(String(clamped));
onChange(clamped);
}}
keyboardType="number-pad"
style={styles.numberInput}
/>
);
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
editorValueFromRule(rule, eventStart),
);
const [showUntilPicker, setShowUntilPicker] = React.useState(false);
const update = (patch: Partial<RecurrenceEditorValue>) =>
setValue((prev) => ({ ...prev, ...patch }));
const toggleWeekDay = (day: string) => {
setValue((prev) => ({
...prev,
weekDays: prev.weekDays.includes(day)
? prev.weekDays.length > 1
? prev.weekDays.filter((d) => d !== day)
: prev.weekDays
: [...prev.weekDays, day],
}));
};
const builtRule = buildRuleFromEditorValue(value, eventStart);
const summary = buildRecurrenceSummary(builtRule);
return (
<View style={styles.container}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Every</Text>
<NumberInput
value={value.interval}
min={1}
max={999}
onChange={(interval) => update({ interval })}
/>
<InlineSelect<EditorFrequency>
flex
value={value.frequency}
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
onChange={(frequency) => update({ frequency })}
/>
</View>
{value.frequency === 'weekly' && (
<View style={styles.weekdayRow}>
{WEEKDAYS.map((day) => {
const active = value.weekDays.includes(day);
return (
<Pressable
key={day}
onPress={() => toggleWeekDay(day)}
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
>
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
{weekdayName(day, 'short')}
</Text>
</Pressable>
);
})}
</View>
)}
{value.frequency === 'yearly' && (
<View style={styles.row}>
<Text style={styles.rowLabel}>In</Text>
<InlineSelect<number>
flex
value={value.month}
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
value: m,
label: capitalize(monthName(m)),
}))}
onChange={(month) => update({ month })}
/>
</View>
)}
{(value.frequency === 'monthly' || value.frequency === 'yearly') && (
<View style={styles.row}>
<InlineSelect<MonthlyMode>
value={value.monthlyMode}
options={[
{ value: 'day', label: 'On day' },
{ value: 'nth', label: 'On the' },
]}
onChange={(monthlyMode) => update({ monthlyMode })}
/>
{value.monthlyMode === 'day' ? (
<NumberInput
value={value.monthDay}
min={1}
max={31}
onChange={(monthDay) => update({ monthDay })}
/>
) : (
<>
<InlineSelect<number>
flex
value={value.nth}
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
onChange={(nth) => update({ nth })}
/>
<InlineSelect<string>
flex
value={value.nthDay}
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
onChange={(nthDay) => update({ nthDay })}
/>
</>
)}
</View>
)}
<View style={styles.row}>
<Text style={styles.rowLabel}>Ends</Text>
<InlineSelect<EndsMode>
flex={value.endsMode === 'never'}
value={value.endsMode}
options={[
{ value: 'never', label: 'Never' },
{ value: 'on', label: 'On date' },
{ value: 'after', label: 'After' },
]}
onChange={(endsMode) => update({ endsMode })}
/>
{value.endsMode === 'on' && (
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
<Text style={styles.selectText}>
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
</Text>
</Pressable>
)}
{value.endsMode === 'after' && (
<>
<NumberInput
value={value.count}
min={1}
max={999}
onChange={(count) => update({ count })}
/>
<Text style={styles.muted}>occurrences</Text>
</>
)}
</View>
<View style={styles.footer}>
<Text style={styles.summary} numberOfLines={2}>
{summary}
</Text>
<View style={styles.footerButtons}>
<Button variant="outline" size="sm" onPress={onCancel}>
Cancel
</Button>
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
Done
</Button>
</View>
</View>
{showUntilPicker && (
<DateTimePicker
value={parseISO(value.untilDate)}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowUntilPicker(false);
if (d) update({ untilDate: format(d, 'yyyy-MM-dd') });
}}
/>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
padding: spacing.md,
gap: spacing.sm,
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
rowLabel: { ...typography.body, color: c.textSecondary },
muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
numberInput: {
minWidth: 52,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlign: 'center',
},
selectFlex: { flex: 1, minWidth: 0 },
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 36,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
selectText: { ...typography.body, color: c.text, flexShrink: 1 },
popover: {
marginTop: spacing.xs,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.xs,
backgroundColor: c.background,
overflow: 'hidden',
},
popoverRow: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
popoverRowActive: { backgroundColor: c.primaryBg },
popoverRowText: { ...typography.body, color: c.text },
weekdayRow: { flexDirection: 'row', gap: 4 },
weekdayChip: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
},
weekdayChipActive: {
borderColor: c.primary,
backgroundColor: c.primaryBg,
},
weekdayChipText: { ...typography.caption, color: c.textMuted },
weekdayChipTextActive: { color: c.primary },
footer: {
borderTopWidth: 1,
borderTopColor: c.border,
paddingTop: spacing.sm,
gap: spacing.sm,
},
summary: { ...typography.caption, color: c.textMuted },
footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
});
}
+92 -73
View File
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { useCalendarStore } from '../../stores/calendar-store';
import { getEventColor } from '../../lib/calendar-utils';
import SenderAvatar from '../SenderAvatar';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const EMAIL_LIMIT = 5;
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const calendarEvents = useCalendarStore((s) => s.events);
const calendars = useCalendarStore((s) => s.calendars);
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
const addressKey = addresses.join(',');
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.wrap}>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MailIcon size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Recent emails</Text>
</View>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
<View style={styles.sectionIcon}>
<MailIcon size={16} color={c.textMuted} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
return (
<Pressable
key={email.id}
onPress={() =>
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
<View style={styles.rowContent}>
<View style={styles.rowTop}>
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
<Text style={styles.sectionLabel}>Recent emails</Text>
</View>
<View style={styles.sectionBody}>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
return (
<Pressable
key={email.id}
onPress={() =>
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
<View style={styles.rowContent}>
<View style={styles.rowTop}>
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
) : null}
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
) : null}
</View>
</Pressable>
);
})
)}
</Pressable>
);
})
)}
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<CalendarDays size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Upcoming events</Text>
<View style={styles.sectionIcon}>
<CalendarDays size={16} color={c.textMuted} />
</View>
<Text style={styles.sectionLabel}>Upcoming events</Text>
</View>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
</Text>
<View style={styles.sectionBody}>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<View
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
/>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
</Text>
</View>
</View>
</View>
))
)}
))
)}
</View>
</View>
</View>
);
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
section: {
backgroundColor: c.card,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
borderLeftWidth: 3,
borderLeftColor: c.primary,
wrap: { gap: spacing.lg },
section: { gap: spacing.sm },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: {
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
fontSize: 11,
letterSpacing: 0.6,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
sectionTitle: { ...typography.bodySemibold, color: c.text },
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: c.textMuted },
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
rowPreview: { ...typography.caption, color: c.textMuted },
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
eventDot: {
width: componentSizes.eventDot,
height: componentSizes.eventDot,
borderRadius: componentSizes.eventDot / 2,
alignSelf: 'center',
},
eventTime: {
...typography.caption,
color: c.textMuted,
width: 64,
width: 52,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: c.text },
+6 -1
View File
@@ -23,7 +23,12 @@ export default function TagAssignSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const keywords = useContactsStore(selectKeywordsUsed);
// Subscribe to the stable `contacts` array and derive the keyword list in a
// memo. Passing `selectKeywordsUsed` straight to the store would return a
// fresh array on every call, which trips useSyncExternalStore's snapshot
// check and spins into an infinite render loop.
const contacts = useContactsStore((s) => s.contacts);
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
const [input, setInput] = React.useState('');
+425
View File
@@ -0,0 +1,425 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type RolePreset = 'read' | 'readWrite' | 'manager';
// Same presets the webmail's share dialog offers (#408). "custom" rights set
// by another client are shown as-is and left untouched until the user picks a
// preset for that principal.
const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
const PRESET_LABELS: Record<RolePreset, string> = {
read: 'Viewer',
readWrite: 'Editor',
manager: 'Manager',
};
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
function detectPreset(rights: FileNodeRights): RolePreset | 'custom' {
for (const preset of PRESET_ORDER) {
const expected = FILE_PRESETS[preset];
const keys = Object.keys(expected) as (keyof FileNodeRights)[];
if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset;
}
return 'custom';
}
interface ShareSheetProps {
node: FileNode | null;
onClose: () => void;
/** Fired after any successful share change so the parent can refresh. */
onChanged: () => void;
}
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
const [shares, setShares] = useState<Record<string, FileNodeRights>>({});
const [search, setSearch] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
if (!node) return;
setShares(node.shareWith ?? {});
setSearch('');
setLoadingPrincipals(true);
getPrincipals()
.then(setPrincipals)
.catch(() => setPrincipals([]))
.finally(() => setLoadingPrincipals(false));
}, [node]);
const principalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
const sharedEntries = useMemo(
() => Object.entries(shares).filter(([, rights]) => rights != null),
[shares],
);
const candidates = useMemo(() => {
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
.filter((p) => p.id !== self && shares[p.id] == null)
.filter((p) =>
!q ||
p.name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q))
.slice(0, 25);
}, [principals, shares, search]);
const applyShare = useCallback(
async (principalId: string, rights: FileNodeRights | null) => {
if (!node || savingId) return;
setSavingId(principalId);
try {
await setFileNodeShare(node.id, principalId, rights);
setShares((prev) => {
const next = { ...prev };
if (rights == null) delete next[principalId];
else next[principalId] = rights;
return next;
});
onChanged();
} catch (e) {
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
} finally {
setSavingId(null);
}
},
[node, savingId, onChanged],
);
if (!node) return null;
const Icon = isFolder(node) ? Folder : FileText;
const renderPrincipalLabel = (principalId: string) => {
const p = principalsById.get(principalId);
return (
<View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || principalId}
</Text>
{p?.email ? (
<Text style={styles.principalEmail} numberOfLines={1}>
{p.email}
</Text>
) : null}
</View>
);
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
Share “{node.name}”
</Text>
</View>
{isFolder(node) ? (
<Text style={styles.subtitle}>
Sharing a folder shares everything inside it.
</Text>
) : null}
<ScrollView
style={styles.scroll}
keyboardShouldPersistTaps="handled"
>
{sharedEntries.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionLabel}>Shared with</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectPreset(rights);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
{renderPrincipalLabel(principalId)}
<View style={styles.roleChips}>
{PRESET_ORDER.map((p) => (
<Pressable
key={p}
onPress={() => void applyShare(principalId, FILE_PRESETS[p])}
disabled={busy}
style={[styles.chip, preset === p && styles.chipActive]}
>
<Text
style={[styles.chipText, preset === p && styles.chipTextActive]}
>
{PRESET_LABELS[p]}
</Text>
</Pressable>
))}
{preset === 'custom' ? (
<Text style={styles.customLabel}>Custom</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Pressable
onPress={() => void applyShare(principalId, null)}
hitSlop={8}
accessibilityLabel="Remove access"
>
<Trash2 size={18} color={c.error} />
</Pressable>
)}
</View>
);
})}
</View>
) : (
<View style={styles.emptyRow}>
<Users size={18} color={c.textMuted} />
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Add people</Text>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search by name or email"
placeholderTextColor={c.textMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
/>
{loadingPrincipals ? (
<ActivityIndicator
size="small"
color={c.textMuted}
style={{ marginTop: spacing.sm }}
/>
) : candidates.length === 0 ? (
<Text style={styles.noMatches}>
{principals.length === 0 ? 'No other users found' : 'No matches'}
</Text>
) : (
candidates.map((p) => (
<Pressable
key={p.id}
onPress={() => void applyShare(p.id, FILE_PRESETS.read)}
disabled={savingId != null}
style={({ pressed }) => [
styles.candidateRow,
pressed && { backgroundColor: c.surfaceHover },
]}
>
{renderPrincipalLabel(p.id)}
{savingId === p.id ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<UserPlus size={18} color={c.primary} />
)}
</Pressable>
))
)}
</View>
</ScrollView>
<Pressable onPress={onClose} style={styles.doneBtn}>
<Text style={styles.doneText}>Done</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.md,
maxHeight: '80%',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
},
title: {
...typography.h3,
color: c.text,
flexShrink: 1,
},
subtitle: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
marginTop: 2,
},
scroll: {
marginTop: spacing.sm,
},
section: {
marginTop: spacing.md,
},
sectionLabel: {
fontSize: typography.caption.fontSize,
fontWeight: '600' as const,
color: c.textMuted,
textTransform: 'uppercase' as const,
paddingHorizontal: spacing.sm,
marginBottom: spacing.xs,
},
shareRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
principalInfo: {
flex: 1,
minWidth: 0,
},
principalName: {
fontSize: typography.body.fontSize,
color: c.text,
},
principalEmail: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
roleChips: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
chip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
chipActive: {
backgroundColor: c.primaryBg,
borderColor: c.primary,
},
chipText: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
chipTextActive: {
color: c.primary,
fontWeight: '600' as const,
},
customLabel: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
fontStyle: 'italic' as const,
},
emptyRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.md,
},
emptyText: {
fontSize: typography.body.fontSize,
color: c.textMuted,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
marginHorizontal: spacing.sm,
},
noMatches: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
candidateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
doneBtn: {
marginTop: spacing.md,
alignSelf: 'stretch',
alignItems: 'center',
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
doneText: {
color: c.primaryForeground,
fontWeight: '600' as const,
fontSize: typography.body.fontSize,
},
});
}
+467
View File
@@ -0,0 +1,467 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, Plus, Trash2 } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Select, ToggleSwitch } from '../settings/settings-section';
import Input from '../Input';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree';
import { generateUUID } from '../../lib/uuid';
import type { Mailbox } from '../../api/types';
import type {
FilterRule,
FilterCondition,
FilterAction,
FilterConditionField,
FilterComparator,
FilterActionType,
} from '../../lib/sieve/types';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body'];
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
const ALL_ACTION_TYPES: FilterActionType[] = ['move', 'copy', 'forward', 'mark_read', 'star', 'add_label', 'discard', 'reject', 'keep', 'stop'];
const ACTIONS_WITH_VALUE = new Set<FilterActionType>(['move', 'copy', 'forward', 'reject', 'add_label']);
const ACTIONS_WITH_MAILBOX = new Set<FilterActionType>(['move', 'copy']);
function makeEmptyCondition(): FilterCondition {
return { field: 'from', comparator: 'contains', value: '' };
}
function makeEmptyAction(): FilterAction {
return { type: 'move', value: '' };
}
interface FilterRuleModalProps {
visible: boolean;
rule?: FilterRule;
mailboxes: Mailbox[];
onSave: (rule: FilterRule) => void;
onClose: () => void;
}
// Flatten the mailbox tree into pickable options, building the Sieve-canonical
// folder path for each (inbox -> "INBOX", not the localized display name).
function buildMailboxOptions(mailboxes: Mailbox[]): { value: string; label: string }[] {
const tree = buildMailboxTree(mailboxes);
const options: { value: string; label: string }[] = [];
const walk = (nodes: MailboxNode[], parentPath: string) => {
for (const node of nodes) {
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
options.push({ value: fullPath, label: `${' '.repeat(node.depth * 3)}${node.name}` });
if (node.children.length > 0) walk(node.children, fullPath);
}
};
walk(tree, '');
return options;
}
export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: FilterRuleModalProps) {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const keywords = useKeywordsStore((s) => s.keywords);
const isEdit = !!rule;
const [name, setName] = useState(rule?.name || '');
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
const [conditions, setConditions] = useState<FilterCondition[]>(
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()],
);
const [actions, setActions] = useState<FilterAction[]>(
rule?.actions.length ? [...rule.actions] : [makeEmptyAction()],
);
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
const mailboxOptions = useMemo(() => buildMailboxOptions(mailboxes), [mailboxes]);
const fieldOptions = useMemo(
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
[t],
);
const actionTypeOptions = useMemo(
() => ALL_ACTION_TYPES.map((a) => ({ value: a, label: t(`settings.filters.action_types.${a}`, a) })),
[t],
);
const keywordOptions = useMemo(
() => keywords.map((kw) => ({ value: kw.id, label: kw.label })),
[keywords],
);
const comparatorOptions = useCallback(
(field: FilterConditionField) =>
(field === 'size' ? SIZE_COMPARATORS : TEXT_COMPARATORS).map((cmp) => ({
value: cmp,
label: t(`settings.filters.comparators.${cmp}`, cmp),
})),
[t],
);
const updateCondition = (index: number, updates: Partial<FilterCondition>) => {
setConditions((prev) =>
prev.map((cond, i) => {
if (i !== index) return cond;
const updated = { ...cond, ...updates };
if (updates.field === 'size' && !SIZE_COMPARATORS.includes(cond.comparator)) {
updated.comparator = 'greater_than';
}
if (updates.field && updates.field !== 'size' && SIZE_COMPARATORS.includes(cond.comparator)) {
updated.comparator = 'contains';
}
if (updates.field && updates.field !== 'header') {
delete updated.headerName;
}
return updated;
}),
);
};
const removeCondition = (index: number) => {
if (conditions.length <= 1) return;
setConditions((prev) => prev.filter((_, i) => i !== index));
};
const updateAction = (index: number, updates: Partial<FilterAction>) => {
setActions((prev) =>
prev.map((act, i) => {
if (i !== index) return act;
const updated = { ...act, ...updates };
if (updates.type && !ACTIONS_WITH_VALUE.has(updates.type)) {
delete updated.value;
}
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
updated.value = mailboxOptions[0]?.value ?? '';
}
return updated;
}),
);
};
const removeAction = (index: number) => {
if (actions.length <= 1) return;
setActions((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = useCallback(() => {
const trimmedName = name.trim();
if (!trimmedName) {
Alert.alert(t('settings.filters.validation_empty_name', 'Rule name is required'));
return;
}
const validConditions = conditions.filter((cond) => cond.value.trim());
if (validConditions.length === 0) {
Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required'));
return;
}
const validActions = actions.filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim());
if (validActions.length === 0) {
Alert.alert(t('settings.filters.validation_empty_actions', 'At least one action is required'));
return;
}
onSave({
id: rule?.id || generateUUID(),
name: trimmedName,
enabled: rule?.enabled ?? true,
matchType,
conditions: validConditions,
actions: validActions,
stopProcessing,
});
}, [name, conditions, actions, matchType, stopProcessing, rule, onSave, t]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>
{isEdit ? t('settings.filters.edit_rule', 'Edit Rule') : t('settings.filters.new_rule', 'New Rule')}
</Text>
<View style={styles.headerRightSpacer} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Name */}
<View>
<Text style={styles.label}>{t('settings.filters.rule_name', 'Rule Name')}</Text>
<Input
value={name}
onChangeText={setName}
placeholder={t('settings.filters.rule_name_placeholder', 'e.g., Sort newsletters')}
maxLength={200}
/>
</View>
{/* Match type */}
<View>
<Text style={styles.label}>{t('settings.filters.match_type', 'Match type')}</Text>
<View style={styles.matchRow}>
{(['all', 'any'] as const).map((mt) => {
const selected = matchType === mt;
return (
<Pressable
key={mt}
onPress={() => setMatchType(mt)}
style={[styles.matchBtn, selected ? styles.matchBtnOn : styles.matchBtnOff]}
>
<Text style={selected ? styles.matchTextOn : styles.matchTextOff}>
{mt === 'all'
? t('settings.filters.match_all', 'Match ALL conditions')
: t('settings.filters.match_any', 'Match ANY condition')}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* Conditions */}
<View>
<Text style={styles.label}>{t('settings.filters.conditions', 'Conditions')}</Text>
<View style={{ gap: spacing.sm }}>
{conditions.map((condition, index) => (
<View key={index} style={styles.card}>
<View style={styles.cardTopRow}>
<Select
value={condition.field}
onChange={(v) => updateCondition(index, { field: v as FilterConditionField })}
options={fieldOptions}
style={{ flex: 1 }}
/>
<Pressable
onPress={() => removeCondition(index)}
disabled={conditions.length <= 1}
hitSlop={8}
style={[styles.removeBtn, conditions.length <= 1 && styles.removeBtnDisabled]}
>
<Trash2 size={16} color={c.mutedForeground} />
</Pressable>
</View>
{condition.field === 'header' && (
<Input
value={condition.headerName || ''}
onChangeText={(v) => updateCondition(index, { headerName: v })}
placeholder={t('settings.filters.header_name', 'Header name')}
/>
)}
<Select
value={condition.comparator}
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
options={comparatorOptions(condition.field)}
style={{ alignSelf: 'flex-start' }}
/>
<Input
value={condition.value}
onChangeText={(v) => updateCondition(index, { value: v })}
placeholder={
condition.field === 'size'
? t('settings.filters.size_placeholder', 'e.g., 1000000')
: t('settings.filters.header_placeholder', 'Value')
}
keyboardType={condition.field === 'size' ? 'numeric' : 'default'}
/>
</View>
))}
</View>
<Pressable
onPress={() => setConditions((prev) => [...prev, makeEmptyCondition()])}
style={styles.addRow}
>
<Plus size={14} color={c.primary} />
<Text style={styles.addText}>{t('settings.filters.add_condition', 'Add Condition')}</Text>
</Pressable>
</View>
{/* Actions */}
<View>
<Text style={styles.label}>{t('settings.filters.actions', 'Actions')}</Text>
<View style={{ gap: spacing.sm }}>
{actions.map((action, index) => (
<View key={index} style={styles.card}>
<View style={styles.cardTopRow}>
<Select
value={action.type}
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
options={actionTypeOptions}
style={{ flex: 1 }}
/>
<Pressable
onPress={() => removeAction(index)}
disabled={actions.length <= 1}
hitSlop={8}
style={[styles.removeBtn, actions.length <= 1 && styles.removeBtnDisabled]}
>
<Trash2 size={16} color={c.mutedForeground} />
</Pressable>
</View>
{ACTIONS_WITH_MAILBOX.has(action.type) && (
mailboxOptions.length > 0 ? (
<Select
value={action.value || ''}
onChange={(v) => updateAction(index, { value: v })}
options={mailboxOptions}
style={{ alignSelf: 'stretch' }}
/>
) : (
<Input
value={action.value || ''}
onChangeText={(v) => updateAction(index, { value: v })}
placeholder={t('settings.filters.move_to_folder', 'Select folder')}
/>
)
)}
{action.type === 'forward' && (
<Input
value={action.value || ''}
onChangeText={(v) => updateAction(index, { value: v })}
placeholder={t('settings.filters.forward_placeholder', 'email@example.com')}
keyboardType="email-address"
autoCapitalize="none"
/>
)}
{action.type === 'reject' && (
<Input
value={action.value || ''}
onChangeText={(v) => updateAction(index, { value: v })}
placeholder={t('settings.filters.reject_placeholder', 'Your email has been rejected')}
/>
)}
{action.type === 'add_label' && (
keywordOptions.length > 0 ? (
<Select
value={action.value || ''}
onChange={(v) => updateAction(index, { value: v })}
options={keywordOptions}
style={{ alignSelf: 'stretch' }}
/>
) : (
<Input
value={action.value || ''}
onChangeText={(v) => updateAction(index, { value: v })}
placeholder={t('settings.filters.label_placeholder', 'Select tag')}
/>
)
)}
</View>
))}
</View>
<Pressable
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
style={styles.addRow}
>
<Plus size={14} color={c.primary} />
<Text style={styles.addText}>{t('settings.filters.add_action', 'Add Action')}</Text>
</Pressable>
</View>
{/* Stop processing */}
<View style={styles.stopRow}>
<Text style={styles.stopLabel}>
{t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
</Text>
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} />
</View>
</ScrollView>
</KeyboardAvoidingView>
<View style={styles.footer}>
<Button variant="outline" onPress={onClose}>
{t('settings.filters.cancel', 'Cancel')}
</Button>
<Button onPress={handleSave} disabled={!name.trim()}>
{t('settings.filters.save', 'Save')}
</Button>
</View>
</SafeAreaView>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerClose: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
headerRightSpacer: { width: 40 },
body: { padding: spacing.lg, gap: spacing.xl, paddingBottom: spacing.xxxl },
label: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
matchRow: { flexDirection: 'row', gap: spacing.sm },
matchBtn: { paddingHorizontal: spacing.md, paddingVertical: 8, borderRadius: radius.sm },
matchBtnOn: { backgroundColor: c.primary },
matchBtnOff: { backgroundColor: c.muted },
matchTextOn: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
matchTextOff: { ...typography.caption, color: c.text },
card: {
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
},
cardTopRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
removeBtn: {
width: 36, height: 36,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
removeBtnDisabled: { opacity: 0.3 },
addRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: spacing.sm },
addText: { ...typography.body, color: c.primary, fontWeight: '500' },
stopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
},
stopLabel: { ...typography.body, color: c.text, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
+255
View File
@@ -0,0 +1,255 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
interface SieveEditorSheetProps {
visible: boolean;
content: string;
onSave: (content: string) => void;
onClose: () => void;
onValidate: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
}
export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate }: SieveEditorSheetProps) {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [script, setScript] = useState(content);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<{ isValid: boolean; errors?: string[] } | null>(null);
const [showSaveWarning, setShowSaveWarning] = useState(false);
// Re-seed the editor whenever it's (re)opened with fresh content. Keying the
// editor on `content` while closed avoids a stale draft on the next open.
React.useEffect(() => {
if (visible) {
setScript(content);
setValidationResult(null);
setShowSaveWarning(false);
}
}, [visible, content]);
const lineCount = script.split('\n').length;
const handleValidate = useCallback(async () => {
setIsValidating(true);
setValidationResult(null);
try {
const result = await onValidate(script);
setValidationResult(result);
} catch {
setValidationResult({ isValid: false, errors: [t('settings.filters.sieve_editor.validation_failed', 'Validation request failed')] });
} finally {
setIsValidating(false);
}
}, [script, onValidate, t]);
const handleSave = useCallback(() => {
if (!showSaveWarning) {
setShowSaveWarning(true);
return;
}
onSave(script);
}, [script, showSaveWarning, onSave]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('settings.filters.sieve_editor.title', 'Sieve Script Editor')}</Text>
<View style={styles.headerRightSpacer} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.body}>
<View style={styles.warningBanner}>
<AlertTriangle size={16} color={c.warning} />
<Text style={styles.warningText}>
{t('settings.filters.sieve_editor.warning', 'Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.')}
</Text>
</View>
<ScrollView style={styles.editorWrap} contentContainerStyle={styles.editorContent}>
<TextInput
value={script}
onChangeText={(v) => {
setScript(v);
setValidationResult(null);
setShowSaveWarning(false);
}}
style={styles.editor}
multiline
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
textAlignVertical="top"
placeholder={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
/>
</ScrollView>
<Text style={styles.lineCount}>{lineCount} {lineCount === 1 ? 'line' : 'lines'}</Text>
{validationResult && (
<View style={[styles.resultBanner, validationResult.isValid ? styles.resultOk : styles.resultErr]}>
{validationResult.isValid ? (
<>
<CheckCircle size={16} color={c.success} />
<Text style={[styles.resultText, { color: c.success }]}>
{t('settings.filters.sieve_editor.valid', 'Script is valid')}
</Text>
</>
) : (
<>
<AlertTriangle size={16} color={c.error} />
<View style={{ flex: 1 }}>
<Text style={[styles.resultText, { color: c.error, fontWeight: '600' }]}>
{t('settings.filters.sieve_editor.invalid', 'Script has errors')}
</Text>
{validationResult.errors?.map((err, i) => (
<Text key={i} style={styles.errorDetail}>{err}</Text>
))}
</View>
</>
)}
</View>
)}
{showSaveWarning && (
<View style={styles.warningBanner}>
<AlertTriangle size={16} color={c.warning} />
<Text style={styles.warningText}>
{t('settings.filters.sieve_editor.save_warning', 'Saving will overwrite any visual rules. This cannot be undone. Tap Save again to confirm.')}
</Text>
</View>
)}
</View>
</KeyboardAvoidingView>
<View style={styles.footer}>
<Button
variant="outline"
onPress={handleValidate}
disabled={isValidating || !script.trim()}
icon={isValidating ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{isValidating
? t('settings.filters.sieve_editor.validating', 'Validating...')
: t('settings.filters.sieve_editor.validate', 'Validate')}
</Button>
<View style={styles.footerRight}>
<Button variant="outline" onPress={onClose}>
{t('settings.filters.sieve_editor.cancel', 'Cancel')}
</Button>
<Button onPress={handleSave} disabled={!script.trim()}>
{showSaveWarning
? t('settings.filters.sieve_editor.confirm_save', 'Confirm Save')
: t('settings.filters.sieve_editor.save', 'Save')}
</Button>
</View>
</View>
</SafeAreaView>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerClose: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
headerRightSpacer: { width: 40 },
body: { flex: 1, padding: spacing.lg, gap: spacing.md },
warningBanner: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
warningText: { ...typography.caption, color: '#fde68a', flex: 1 },
editorWrap: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
backgroundColor: c.surface,
},
editorContent: { flexGrow: 1 },
editor: {
flex: 1,
minHeight: 240,
padding: spacing.md,
color: c.text,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
fontSize: 13,
lineHeight: 20,
},
lineCount: { ...typography.caption, color: c.mutedForeground, textAlign: 'right' },
resultBanner: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
alignItems: 'flex-start',
},
resultOk: { backgroundColor: c.successBg, borderColor: c.success },
resultErr: { backgroundColor: c.errorBg, borderColor: c.errorBorder },
resultText: { ...typography.caption },
errorDetail: {
...typography.caption,
color: c.error,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
marginTop: 4,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
footerRight: { flexDirection: 'row', gap: spacing.sm },
});
}
+2
View File
@@ -5,3 +5,5 @@ export { default as Badge } from './Badge';
export { Card, SectionHeader, SettingItemRow } from './Card';
export { default as RadioGroup } from './RadioGroup';
export { default as Dialog } from './Dialog';
export { IdentitySheet } from './IdentitySheet';
+30 -1
View File
@@ -8,6 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -22,6 +23,14 @@ const DAY_OPTIONS = [
{ value: '90', label: 'Last 90 days' },
];
const MAX_MB_OPTIONS = [
{ value: '25', label: '25 MB' },
{ value: '50', label: '50 MB' },
{ value: '100', label: '100 MB' },
{ value: '250', label: '250 MB' },
{ value: '500', label: '500 MB' },
];
function formatRelativeTime(ms: number | undefined): string {
if (!ms) return 'never';
const diff = Date.now() - ms;
@@ -58,7 +67,10 @@ export function AboutDataSettings() {
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const queuedChanges = useOutboxStore((s) => s.entries.length);
const flushOutbox = useOutboxStore((s) => s.flush);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
@@ -73,7 +85,9 @@ export function AboutDataSettings() {
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays });
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it.
void flushOutbox();
};
const handleClearCache = () => {
@@ -142,6 +156,16 @@ export function AboutDataSettings() {
options={DAY_OPTIONS}
/>
</SettingItem>
<SettingItem
label="Maximum size"
description="Oldest messages are removed when the cache grows past this."
>
<Select
value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={MAX_MB_OPTIONS}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
@@ -161,6 +185,11 @@ export function AboutDataSettings() {
? `Last sync failed: ${sync.message ?? 'unknown error'}`
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
</Text>
{queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}>
{`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`}
</Text>
)}
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}>
<View
@@ -1,6 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
@@ -13,45 +17,44 @@ import {
isClientCertSupported,
pickClientCertAlias,
} from '../../lib/client-cert';
import {
isStalwartSupported,
fetchAuthInfo,
fetchEncryptionType,
fetchPrincipal,
changePassword,
updateDisplayName,
enableTotp,
disableTotp,
createAppPassword,
removeAppPassword,
createApiKey,
removeApiKey,
type AppCredentialInfo,
type AppCredentialInput,
type AuthInfo,
type EncryptionType,
} from '../../api/account-security';
import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp';
interface Session {
id: string;
device: string;
location: string;
current: boolean;
}
const MOCK_SESSIONS: Session[] = [
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
];
export function AccountSecuritySettings() {
// ── TLS client certificate (mobile-only) ──────────────────
// Picks an installed Android cert for mTLS handshakes. Independent of the
// Stalwart account features below, so it stays visible even on non-Stalwart
// servers.
function ClientCertSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [twoFactor, setTwoFactor] = useState(false);
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
const certSupported = isClientCertSupported();
const [certAlias, setCertAlias] = useState<string | null>(null);
const [certBusy, setCertBusy] = useState(false);
useEffect(() => {
if (!certSupported) return;
void getClientCertAlias().then(setCertAlias);
}, [certSupported]);
}, []);
const onPickCert = async () => {
if (certBusy) return;
setCertBusy(true);
try {
// Hint the picker with the JMAP server host so it can pre-filter to certs
// issued for it when the OEM picker honours that.
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
const alias = await pickClientCertAlias(host);
setCertAlias(alias);
@@ -62,7 +65,7 @@ export function AccountSecuritySettings() {
}
};
const onClearCert = async () => {
const onClearCert = () => {
if (certBusy) return;
Alert.alert(
'Stop using client certificate?',
@@ -87,218 +90,644 @@ export function AccountSecuritySettings() {
};
return (
<View style={styles.container}>
{certSupported && (
<SettingsSection
title="TLS client certificate"
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.certStatus}>
{certAlias ? `Active: ${certAlias}` : 'Not set'}
</Text>
<Text style={styles.certHint}>
Install the certificate via Android Settings → Security → Encryption &amp; credentials, then pick it here.
</Text>
</View>
<SettingsSection
title="TLS client certificate"
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.certStatus}>{certAlias ? `Active: ${certAlias}` : 'Not set'}</Text>
<Text style={styles.certHint}>
Install the certificate via Android Settings → Security → Encryption &amp; credentials, then pick it here.
</Text>
</View>
</View>
<View style={styles.rowGap}>
<Button variant="outline" size="sm" disabled={certBusy} onPress={() => { void onPickCert(); }}>
{certAlias ? 'Choose another' : 'Pick certificate'}
</Button>
{certAlias && (
<Button variant="outline" size="sm" disabled={certBusy} onPress={onClearCert}>
Clear
</Button>
)}
</View>
</SettingsSection>
);
}
// ── Password change ───────────────────────────────────────
function PasswordChangeSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = async () => {
setError(null);
if (newPwd.length < 8) { setError('New password must be at least 8 characters.'); return; }
if (newPwd !== confirmPwd) { setError('Passwords do not match.'); return; }
setSaving(true);
try {
await changePassword(currentPwd, newPwd);
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
Alert.alert('Password changed', 'Your account password was updated.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password.');
} finally {
setSaving(false);
}
};
return (
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Current password</Text>
<View style={styles.pwInputRow}>
<Input value={currentPwd} onChangeText={setCurrentPwd} secureTextEntry={!showCurrent} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
{showCurrent ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
</Pressable>
</View>
<View style={styles.certActions}>
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onPickCert(); }}
>
{certAlias ? 'Choose another' : 'Pick certificate'}
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>New password</Text>
<View style={styles.pwInputRow}>
<Input value={newPwd} onChangeText={setNewPwd} secureTextEntry={!showNew} autoCapitalize="none" containerStyle={{ flex: 1 }} />
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
{showNew ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
</Pressable>
</View>
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Confirm password</Text>
<Input value={confirmPwd} onChangeText={setConfirmPwd} secureTextEntry={!showNew} autoCapitalize="none" />
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<View style={{ alignItems: 'flex-end' }}>
<Button
size="sm"
loading={saving}
disabled={saving || !currentPwd || !newPwd || !confirmPwd}
onPress={() => { void submit(); }}
icon={<Key size={14} color={c.primaryForeground} />}
>
Change Password
</Button>
</View>
</View>
</SettingsSection>
);
}
// ── Display name ──────────────────────────────────────────
function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (name: string) => void }) {
const c = useColors();
const [name, setName] = useState(initial);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => { setName(initial); }, [initial]);
const save = async () => {
setSaving(true);
try {
await updateDisplayName(name);
onSaved(name);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
Alert.alert('Update failed', err instanceof Error ? err.message : 'Failed to update display name.');
} finally {
setSaving(false);
}
};
return (
<SettingItem label="Display name" description="The name shown on this account.">
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Input value={name} onChangeText={setName} placeholder="Display name" containerStyle={{ width: 150 }} />
<Button size="sm" loading={saving} disabled={saving || name === initial} onPress={() => { void save(); }}>
{saved ? <Check size={14} color={c.primaryForeground} /> : 'Save'}
</Button>
</View>
</SettingItem>
);
}
// ── Two-factor authentication ─────────────────────────────
function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [enrolment, setEnrolment] = useState<TotpEnrolment | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const reset = () => {
setEnrolment(null); setDisableOpen(false);
setPassword(''); setOtpCode(''); setError(null);
};
const handleToggle = (enable: boolean) => {
setError(''); setPassword(''); setOtpCode('');
if (enable) {
setEnrolment(generateTotpEnrolment(jmapClient.username ?? 'account'));
setDisableOpen(false);
} else {
setEnrolment(null);
setDisableOpen(true);
}
};
const confirmEnable = async () => {
if (!enrolment) return;
if (!password) { setError('Enter your current password.'); return; }
if (!otpCode.trim()) { setError('Enter the 6-digit code from your app.'); return; }
setSaving(true);
try {
await enableTotp(password, enrolment.url, otpCode.trim());
reset();
onChanged();
Alert.alert('Two-factor enabled', 'You will be asked for a code at next sign-in.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable two-factor authentication.');
} finally {
setSaving(false);
}
};
const confirmDisable = async () => {
if (!password) { setError('Enter your current password.'); return; }
setSaving(true);
try {
await disableTotp(password);
reset();
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable two-factor authentication.');
} finally {
setSaving(false);
}
};
return (
<View style={{ gap: spacing.md }}>
<View style={styles.headerRow}>
<Shield size={16} color={c.mutedForeground} />
<Text style={styles.headerTitle}>Two-Factor Authentication</Text>
</View>
<SettingItem label="Authenticator app" description="Require a one-time code at login." noBorder>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<ToggleSwitch checked={enabled || !!enrolment} onChange={handleToggle} disabled={saving} />
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
{enabled ? 'Active' : 'Inactive'}
</Text>
</View>
</SettingItem>
{enrolment && (
<View style={styles.panel}>
<Text style={styles.panelHint}>
Add this secret to your authenticator app, then enter the 6-digit code to confirm.
</Text>
<Button
variant="outline"
size="sm"
icon={<ExternalLink size={14} color={c.text} />}
onPress={() => { void Linking.openURL(enrolment.url).catch(() => Alert.alert('No authenticator app', 'Could not open an authenticator app. Add the secret below manually.')); }}
>
Add to authenticator app
</Button>
<View>
<Text style={styles.pwLabel}>Or enter this secret manually</Text>
<Text selectable style={styles.secretText}>{enrolment.secretFormatted}</Text>
</View>
<View>
<Text style={styles.pwLabel}>Current password</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
</View>
<View>
<Text style={styles.pwLabel}>Verification code</Text>
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
<Button size="sm" loading={saving} disabled={saving || !password || !otpCode} onPress={() => { void confirmEnable(); }}>
Confirm
</Button>
{certAlias && (
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onClearCert(); }}
>
Clear
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
</View>
</View>
)}
{disableOpen && (
<View style={styles.panel}>
<Text style={styles.panelHint}>Enter your password to turn off two-factor authentication.</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder="Current password" />
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
<Button variant="destructive" size="sm" loading={saving} disabled={saving || !password} onPress={() => { void confirmDisable(); }}>
Disable
</Button>
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
</View>
</View>
)}
</View>
);
}
// ── App passwords / API keys ──────────────────────────────
function parseIpList(raw: string): string[] {
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}
interface CredentialSectionProps {
icon: typeof Smartphone;
title: string;
description: string;
nameLabel: string;
namePlaceholder: string;
entries: AppCredentialInfo[];
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
onRemove: (id: string) => Promise<void>;
}
function CredentialSection({ icon: Icon, title, description, nameLabel, namePlaceholder, entries, onCreate, onRemove }: CredentialSectionProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState('');
const [expiry, setExpiry] = useState<Date | null>(null);
const [showPicker, setShowPicker] = useState(false);
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const resetForm = () => { setName(''); setExpiry(null); setAllowedIpsRaw(''); };
const handleAdd = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const result = await onCreate({
description: name.trim(),
expiresAt: expiry ? expiry.toISOString() : null,
allowedIps: parseIpList(allowedIpsRaw),
});
setCreatedSecret(result.secret);
resetForm();
setShowAdd(false);
} catch (err) {
Alert.alert('Could not create', err instanceof Error ? err.message : undefined);
} finally {
setSaving(false);
}
};
const handleRemove = (id: string) => {
Alert.alert('Remove credential?', 'Any client using it will stop working.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await onRemove(id);
} catch (err) {
Alert.alert('Could not remove', err instanceof Error ? err.message : undefined);
}
},
},
]);
};
const onPickDate = (_e: DateTimePickerEvent, date?: Date) => {
setShowPicker(false);
if (date) setExpiry(date);
};
return (
<View style={{ gap: spacing.md }}>
<View style={styles.headerRowBetween}>
<View style={styles.headerRow}>
<Icon size={16} color={c.mutedForeground} />
<Text style={styles.headerTitle}>{title}</Text>
</View>
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />} onPress={() => setShowAdd((v) => !v)}>
Add
</Button>
</View>
<Text style={styles.panelHint}>{description}</Text>
{createdSecret && (
<View style={styles.panel}>
<Text style={styles.panelHint}>Copy this now — it is shown only once. Tap and hold to select.</Text>
<Text selectable style={styles.secretText}>{createdSecret}</Text>
<View style={{ alignItems: 'flex-start' }}>
<Button variant="ghost" size="sm" onPress={() => setCreatedSecret(null)}>Done</Button>
</View>
</View>
)}
{showAdd && (
<View style={styles.panel}>
<View>
<Text style={styles.pwLabel}>{nameLabel}</Text>
<Input value={name} onChangeText={setName} placeholder={namePlaceholder} />
</View>
<View>
<Text style={styles.pwLabel}>Expires (optional)</Text>
<View style={styles.rowGap}>
<Button variant="outline" size="sm" icon={<Calendar size={14} color={c.text} />} onPress={() => setShowPicker(true)}>
{expiry ? expiry.toLocaleDateString() : 'No expiry'}
</Button>
{expiry && (
<Pressable style={styles.clearExpiry} onPress={() => setExpiry(null)}>
<X size={14} color={c.mutedForeground} />
</Pressable>
)}
</View>
{showPicker && (
<DateTimePicker value={expiry ?? new Date()} mode="date" minimumDate={new Date()} onChange={onPickDate} />
)}
</View>
<View>
<Text style={styles.pwLabel}>Allowed IPs (optional)</Text>
<Input value={allowedIpsRaw} onChangeText={setAllowedIpsRaw} placeholder="1.2.3.4, 10.0.0.0/8" autoCapitalize="none" />
<Text style={styles.fieldHint}>Comma or space separated. Leave empty to allow any IP.</Text>
</View>
<View style={styles.rowGap}>
<Button size="sm" loading={saving} disabled={saving || !name.trim()} onPress={() => { void handleAdd(); }}>Create</Button>
<Button variant="ghost" size="sm" onPress={() => { setShowAdd(false); resetForm(); }}>Cancel</Button>
</View>
</View>
)}
{entries.length > 0 ? (
<View style={{ gap: spacing.sm }}>
{entries.map((entry) => (
<View key={entry.id} style={styles.credRow}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.credName} numberOfLines={1}>{entry.description || entry.id}</Text>
{entry.createdAt && (
<Text style={styles.credMeta}>
{new Date(entry.createdAt).toLocaleDateString()}
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
</Text>
)}
{entry.allowedIps.length > 0 && (
<View style={styles.ipWrap}>
{entry.allowedIps.map((ip) => (
<View key={ip} style={styles.ipPill}><Text style={styles.ipText}>{ip}</Text></View>
))}
</View>
)}
</View>
<Pressable style={styles.iconBtn} onPress={() => handleRemove(entry.id)}>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
))}
</View>
) : (
<Text style={styles.emptyText}>None yet.</Text>
)}
</View>
);
}
// ── Email client (OAuth accounts) ─────────────────────────
function EmailClientSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = jmapClient.username ?? '';
return (
<View style={{ gap: spacing.md }}>
<View style={styles.headerRow}>
<Monitor size={16} color={c.mutedForeground} />
<Text style={styles.headerTitle}>Email client setup</Text>
</View>
<Text style={styles.panelHint}>
To configure another mail client, use the username below and an app password (create one above) as the password.
</Text>
<View style={styles.panel}>
<Text style={styles.pwLabel}>JMAP / IMAP username</Text>
<Text selectable style={styles.secretText}>{username}</Text>
</View>
</View>
);
}
// ── Encryption at rest (read-only status) ─────────────────
function EncryptionSection({ type }: { type: EncryptionType }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const enabled = type !== 'Disabled';
return (
<View style={{ gap: spacing.md }}>
<View style={styles.headerRow}>
<Lock size={16} color={c.mutedForeground} />
<Text style={styles.headerTitle}>Encryption at rest</Text>
</View>
<SettingItem label="Stored message encryption" description="Server-side encryption of stored messages." noBorder>
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
{enabled ? `Active (${type})` : 'Disabled'}
</Text>
</SettingItem>
</View>
);
}
// ── Screen ────────────────────────────────────────────────
export function AccountSecuritySettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const certSupported = isClientCertSupported();
const isOAuth = jmapClient.usesBearerAuth;
// null = still probing; false = server lacks the Stalwart extension.
const [supported, setSupported] = useState<boolean | null>(null);
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [displayName, setDisplayName] = useState('');
const [encryption, setEncryption] = useState<EncryptionType>('Disabled');
const [loadError, setLoadError] = useState<string | null>(null);
const reloadAuth = useCallback(async () => {
try {
setAuth(await fetchAuthInfo());
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
}
}, []);
useEffect(() => {
let cancelled = false;
const session = jmapClient.currentSession;
if (!session) { setSupported(false); return; }
if (!isStalwartSupported()) { setSupported(false); return; }
setSupported(true);
(async () => {
try {
const authInfo = await fetchAuthInfo();
if (cancelled) return;
setAuth(authInfo);
if (!isOAuth) {
// Principal + crypto only matter for password accounts; failures are
// non-fatal (e.g. a non-admin principal read is forbidden).
const [principal, enc] = await Promise.allSettled([fetchPrincipal(), fetchEncryptionType()]);
if (cancelled) return;
if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName);
if (enc.status === 'fulfilled') setEncryption(enc.value);
}
} catch (err) {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
}
})();
return () => { cancelled = true; };
}, [isOAuth]);
return (
<View style={styles.container}>
{certSupported && <ClientCertSection />}
{supported === null && (
<View style={styles.loadingRow}>
<ActivityIndicator color={c.mutedForeground} />
<Text style={styles.panelHint}>Detecting server features…</Text>
</View>
)}
{supported === false && (
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
<Text style={styles.emptyText}>
Account security management requires a Stalwart server. These features are not available for this account.
</Text>
</SettingsSection>
)}
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Current password</Text>
<View style={styles.pwInputRow}>
<Input
value={currentPwd}
onChangeText={setCurrentPwd}
secureTextEntry={!showCurrent}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
{showCurrent ? (
<EyeOff size={16} color={c.mutedForeground} />
) : (
<Eye size={16} color={c.mutedForeground} />
)}
</Pressable>
</View>
</View>
{supported && (
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
<View style={{ gap: spacing.xxxl }}>
{loadError ? <Text style={styles.errorText}>{loadError}</Text> : null}
<View style={styles.pwField}>
<Text style={styles.pwLabel}>New password</Text>
<View style={styles.pwInputRow}>
<Input
value={newPwd}
onChangeText={setNewPwd}
secureTextEntry={!showNew}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
{showNew ? (
<EyeOff size={16} color={c.mutedForeground} />
) : (
<Eye size={16} color={c.mutedForeground} />
)}
</Pressable>
</View>
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Confirm password</Text>
<Input
value={confirmPwd}
onChangeText={setConfirmPwd}
secureTextEntry={!showNew}
/>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
Change Password
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Two-Factor Authentication" description="Add an extra layer of security with an authenticator app.">
<SettingItem label="Enable 2FA" description="Require a one-time code at login.">
<ToggleSwitch checked={twoFactor} onChange={setTwoFactor} />
</SettingItem>
{twoFactor && (
<View style={styles.infoBox}>
<Smartphone size={16} color={c.primary} />
<Text style={styles.infoText}>
Scan the QR code in your authenticator app to finish setup.
</Text>
</View>
)}
</SettingsSection>
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
{appPwds.length === 0 ? (
<View style={styles.empty}>
<Lock size={24} color={c.mutedForeground} />
<Text style={styles.emptyText}>No app passwords yet.</Text>
</View>
) : null}
<View style={{ alignItems: 'flex-start' }}>
<Button variant="outline" size="sm">
Generate New
</Button>
</View>
</SettingsSection>
<SettingsSection title="Active Sessions" description="Devices currently signed into your account.">
{MOCK_SESSIONS.map((s) => (
<View key={s.id} style={styles.sessionRow}>
<View style={styles.sessionIcon}>
<Monitor size={18} color={c.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.sessionDevice}>{s.device}</Text>
<Text style={styles.sessionMeta}>
{s.location} · {s.current ? 'Current' : 'Active'}
</Text>
</View>
{!s.current && (
<Pressable style={styles.trashBtn}>
<Trash2 size={16} color={c.error} />
</Pressable>
{!isOAuth && (
<>
<PasswordChangeSection />
<DisplayNameSection initial={displayName} onSaved={setDisplayName} />
<TotpSection enabled={!!auth?.otpEnabled} onChanged={() => { void reloadAuth(); }} />
</>
)}
<CredentialSection
icon={Smartphone}
title="App passwords"
description="Generate passwords for other mail clients that can't do interactive login."
nameLabel="Name"
namePlaceholder="e.g. Thunderbird laptop"
entries={auth?.appPasswords ?? []}
onCreate={async (input) => { const r = await createAppPassword(input); await reloadAuth(); return r; }}
onRemove={async (id) => { await removeAppPassword(id); await reloadAuth(); }}
/>
<CredentialSection
icon={Terminal}
title="API keys"
description="Tokens for scripts and integrations that use the JMAP API."
nameLabel="Name"
namePlaceholder="e.g. Backup script"
entries={auth?.apiKeys ?? []}
onCreate={async (input) => { const r = await createApiKey(input); await reloadAuth(); return r; }}
onRemove={async (id) => { await removeApiKey(id); await reloadAuth(); }}
/>
{isOAuth && <EmailClientSection />}
{!isOAuth && <EncryptionSection type={encryption} />}
</View>
))}
</SettingsSection>
</SettingsSection>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
pwField: { gap: 4 },
pwLabel: { ...typography.caption, color: c.mutedForeground },
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
eyeBtn: { padding: spacing.sm },
infoBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: c.primaryBg,
borderRadius: radius.sm,
alignItems: 'center',
},
infoText: {
...typography.caption,
color: c.primary,
flex: 1,
},
empty: {
alignItems: 'center',
padding: spacing.xl,
gap: spacing.sm,
},
emptyText: {
...typography.body,
color: c.mutedForeground,
},
sessionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sessionIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
backgroundColor: c.muted,
alignItems: 'center',
justifyContent: 'center',
},
sessionDevice: { ...typography.bodyMedium, color: c.text },
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
trashBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
certRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
paddingVertical: spacing.md,
},
certStatus: { ...typography.bodyMedium, color: c.text },
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
});
container: { gap: spacing.xxxl },
rowGap: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.lg },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
headerRowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
headerTitle: { ...typography.bodyMedium, color: c.text },
statusText: { ...typography.caption, fontWeight: '500' },
pwField: { gap: 4 },
pwLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
eyeBtn: { padding: spacing.sm },
fieldHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4, fontSize: 11 },
errorText: { ...typography.caption, color: c.error },
panel: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
},
panelHint: { ...typography.caption, color: c.mutedForeground },
secretText: {
...typography.body,
fontFamily: 'monospace',
color: c.text,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
clearExpiry: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
credRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
},
credName: { ...typography.bodyMedium, color: c.text },
credMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
ipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
ipPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.xs, backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
ipText: { fontSize: 10, fontFamily: 'monospace', color: c.mutedForeground },
iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
emptyText: { ...typography.caption, color: c.mutedForeground, fontStyle: 'italic' },
// TLS client cert
certRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingVertical: spacing.md },
certStatus: { ...typography.bodyMedium, color: c.text },
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
});
}
+8 -1
View File
@@ -22,7 +22,14 @@ export function ContactsSettings() {
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const books = useContactsStore(selectAddressBooksWithCount);
const addressBooks = useContactsStore((s) => s.addressBooks);
// Derive in a memo from stable store fields. Subscribing with
// `selectAddressBooksWithCount` directly returns a new array each call and
// sends useSyncExternalStore into an infinite render loop.
const books = React.useMemo(
() => selectAddressBooksWithCount(addressBooks, contacts),
[addressBooks, contacts],
);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
@@ -0,0 +1,173 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { RotateCcw } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Input from '../Input';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store';
import {
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
DEFAULT_EMAIL_TEMPLATE,
DEFAULT_ATTACHMENT_TEMPLATE,
type EmailFilenameOptions,
} from '../../lib/download-filename';
const SAMPLE = buildSampleEmail();
const SAMPLE_ATTACHMENT = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
export function DownloadsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailTemplate = useSettingsStore((s) => s.emailExportTemplate);
const attachmentTemplate = useSettingsStore((s) => s.attachmentExportTemplate);
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
const lowercase = useSettingsStore((s) => s.exportLowercase);
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const transforms: EmailFilenameOptions = { spaceReplacement, lowercase, stripDiacritics };
const emailPreview = emailExportFilename(SAMPLE, { ...transforms, template: emailTemplate });
const attachmentPreview = attachmentDownloadFilename(SAMPLE, SAMPLE_ATTACHMENT, {
...transforms,
template: attachmentTemplate,
});
return (
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title="Email Filename"
description="Template used when exporting a message as an .eml file."
>
<Input
value={emailTemplate}
onChangeText={(v) => update('emailExportTemplate', v)}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
onPress={() => update('emailExportTemplate', DEFAULT_EMAIL_TEMPLATE)}
style={styles.resetRow}
>
<RotateCcw size={12} color={c.mutedForeground} />
<Text style={styles.resetText}>Reset to default</Text>
</Pressable>
<View style={styles.previewBox}>
<Text style={styles.previewLabel}>Preview</Text>
<Text style={styles.previewValue}>{emailPreview}</Text>
</View>
<TokenList tokens={EMAIL_TOKENS} styles={styles} />
</SettingsSection>
<SettingsSection
title="Attachment Filename"
description="Template used when saving or sharing an attachment."
>
<Input
value={attachmentTemplate}
onChangeText={(v) => update('attachmentExportTemplate', v)}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
onPress={() => update('attachmentExportTemplate', DEFAULT_ATTACHMENT_TEMPLATE)}
style={styles.resetRow}
>
<RotateCcw size={12} color={c.mutedForeground} />
<Text style={styles.resetText}>Reset to default</Text>
</Pressable>
<View style={styles.previewBox}>
<Text style={styles.previewLabel}>Preview</Text>
<Text style={styles.previewValue}>{attachmentPreview}</Text>
</View>
<TokenList tokens={ATTACHMENT_TOKENS} styles={styles} />
</SettingsSection>
<SettingsSection
title="Filename Transform"
description="Applied to every exported filename."
>
<View style={styles.group}>
<SettingItem label="Spaces" description="Replace spaces in filenames." noBorder />
<RadioGroup
value={spaceReplacement}
onChange={(v) => update('exportSpaceReplacement', v as SpaceReplacement)}
options={[
{ value: 'keep', label: 'Keep' },
{ value: 'underscore', label: 'Underscore' },
{ value: 'dash', label: 'Dash' },
]}
/>
</View>
<SettingItem label="Lowercase" description="Force the whole filename to lowercase.">
<ToggleSwitch checked={lowercase} onChange={(v) => update('exportLowercase', v)} />
</SettingItem>
<SettingItem
label="Strip Accents"
description="Convert accented letters to plain ASCII (ä → a)."
>
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
</SettingItem>
</SettingsSection>
</View>
);
}
function TokenList({
tokens,
styles,
}: {
tokens: { token: string; description: string }[];
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.tokenWrap}>
{tokens.map((t) => (
<View key={t.token} style={styles.tokenPill}>
<Text style={styles.tokenText}>{`{${t.token}}`}</Text>
</View>
))}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: { gap: spacing.sm },
resetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.sm,
},
resetText: { ...typography.caption, color: c.mutedForeground },
previewBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
gap: 2,
},
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: c.muted,
},
tokenText: { fontSize: 11, color: c.mutedForeground },
});
}
+404 -124
View File
@@ -1,184 +1,464 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import {
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
ChevronUp, ChevronDown, Palmtree,
} from 'lucide-react-native';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useFilterStore } from '../../stores/filter-store';
import { useVacationStore } from '../../stores/vacation-store';
import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store';
import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types';
interface Rule {
id: string;
name: string;
enabled: boolean;
summary: string;
action: string;
type Translate = (key: string, fallback?: string) => string;
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === 'external' || r.origin === 'opaque';
}
const SAMPLE_RULES: Rule[] = [];
function summarizeRule(rule: FilterRule, t: Translate): string {
const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or');
const conditions = rule.conditions.slice(0, 2).map((cond) => {
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
return `${field} ${comparator} "${cond.value}"`;
});
const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : '';
const actions = rule.actions.slice(0, 2).map((a) => {
const action = t(`settings.filters.action_types.${a.type}`, a.type);
return a.value ? `${action} "${a.value}"` : action;
});
return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`;
}
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
const update = useSettingsStore((s) => s.updateSetting);
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const mailboxes = useEmailStore((s) => s.mailboxes);
const vacationEnabled = useVacationStore((s) => s.isEnabled);
const {
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings,
fetchFilters, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
setOpaqueScript, resetToVisualBuilder, validateScript,
} = useFilterStore();
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
useEffect(() => {
void fetchFilters();
}, [fetchFilters]);
const remove = (id: string) => {
setRules((rs) => rs.filter((r) => r.id !== id));
setDeleteId(null);
};
const showVacationBanner = (vacationEnabled || vacationSettings?.isEnabled) ?? false;
// Editable (Bulwark-managed) rules, in order, for reorder math.
const editableIds = useMemo(
() => rules.filter((r) => !isReadonlyRule(r)).map((r) => r.id),
[rules],
);
const persist = useCallback(async (rollback: () => void) => {
try {
await saveFilters();
} catch {
rollback();
Alert.alert(t('settings.filters.save_failed', 'Failed to save filters'));
}
}, [saveFilters, t]);
const handleToggle = useCallback((ruleId: string) => {
toggleRule(ruleId);
void persist(() => toggleRule(ruleId));
}, [toggleRule, persist]);
const handleDelete = useCallback((rule: FilterRule) => {
Alert.alert(
t('settings.filters.delete_rule', 'Delete Rule'),
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
[
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' },
{
text: t('settings.filters.confirm_delete', 'Delete'),
style: 'destructive',
onPress: () => {
deleteRule(rule.id);
void persist(() => addRule(rule));
},
},
],
);
}, [deleteRule, addRule, persist, t]);
const handleSaveRule = useCallback((rule: FilterRule) => {
const previous = useFilterStore.getState().rules;
if (editingRule) updateRule(rule.id, rule);
else addRule(rule);
setShowRuleModal(false);
setEditingRule(undefined);
void persist(() => useFilterStore.setState({ rules: previous }));
}, [editingRule, updateRule, addRule, persist]);
const handleSaveSieve = useCallback((content: string) => {
const previous = useFilterStore.getState();
setOpaqueScript(content);
setShowSieveEditor(false);
void persist(() => useFilterStore.setState({
isOpaque: previous.isOpaque,
rules: previous.rules,
rawScript: previous.rawScript,
}));
}, [setOpaqueScript, persist]);
const handleResetToVisual = useCallback(() => {
if (!showResetConfirm) {
setShowResetConfirm(true);
return;
}
const previous = useFilterStore.getState();
resetToVisualBuilder();
setShowResetConfirm(false);
void persist(() => useFilterStore.setState({
isOpaque: previous.isOpaque,
rules: previous.rules,
rawScript: previous.rawScript,
externalRequires: previous.externalRequires,
}));
}, [showResetConfirm, resetToVisualBuilder, persist]);
const moveRule = useCallback((ruleId: string, dir: -1 | 1) => {
const idx = editableIds.indexOf(ruleId);
const target = idx + dir;
if (idx === -1 || target < 0 || target >= editableIds.length) return;
const previousOrder = [...editableIds];
const next = [...editableIds];
[next[idx], next[target]] = [next[target], next[idx]];
reorderRules(next);
void persist(() => reorderRules(previousOrder));
}, [editableIds, reorderRules, persist]);
const handleValidate = useCallback(
(content: string) => validateScript(content),
[validateScript],
);
// ── Status screens ──────────────────────────────────────
if (!isSupported && !isLoading) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<Text style={styles.statusText}>{t('settings.filters.not_supported', 'Your mail server does not support email filters.')}</Text>
</SettingsSection>
);
}
if (isLoading) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<View style={styles.loadingRow}>
<ActivityIndicator size="small" color={c.mutedForeground} />
<Text style={styles.statusText}>{t('settings.filters.loading', 'Loading filters...')}</Text>
</View>
</SettingsSection>
);
}
if (error) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<Text style={[styles.statusText, { color: c.error }]}>{t('settings.filters.fetch_error', 'Failed to load filters')}</Text>
</SettingsSection>
);
}
return (
<View style={styles.container}>
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
<View style={{ gap: spacing.xl }}>
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
{isOpaque && (
<View style={styles.opaqueWarning}>
<AlertTriangle size={16} color="#fbbf24" />
<View style={styles.opaqueBanner}>
<AlertTriangle size={16} color={c.warning} />
<View style={{ flex: 1 }}>
<Text style={styles.opaqueText}>
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
{t('settings.filters.opaque_warning', 'This script was edited outside the visual builder. Only raw Sieve editing is available.')}
</Text>
<View style={styles.opaqueActions}>
<Pressable>
<Text style={styles.linkText}>Open Sieve Editor</Text>
</Pressable>
<Pressable style={styles.resetLink}>
<RotateCcw size={12} color={c.error} />
<Text style={styles.resetText}>Reset to visual</Text>
<Pressable onPress={() => setShowSieveEditor(true)}>
<Text style={styles.linkPrimary}>{t('settings.filters.open_sieve_editor', 'Open raw Sieve editor')}</Text>
</Pressable>
{showResetConfirm ? (
<View style={styles.resetConfirmRow}>
<Pressable onPress={handleResetToVisual}>
<Text style={styles.linkDanger}>{t('settings.filters.confirm_reset', 'Reset')}</Text>
</Pressable>
<Pressable onPress={() => setShowResetConfirm(false)}>
<Text style={styles.linkMuted}>{t('settings.filters.cancel', 'Cancel')}</Text>
</Pressable>
</View>
) : (
<Pressable onPress={handleResetToVisual} style={styles.resetLink}>
<RotateCcw size={12} color={c.error} />
<Text style={styles.linkDanger}>{t('settings.filters.reset_to_visual', 'Reset to visual builder')}</Text>
</Pressable>
)}
</View>
</View>
</View>
)}
{rules.length === 0 && !isOpaque && (
<View style={styles.emptyState}>
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No rules yet.</Text>
{!isOpaque && showVacationBanner && (
<View style={styles.vacationBanner}>
<View style={styles.vacationIcon}>
<Palmtree size={16} color={c.success} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.vacationTitle}>{t('settings.filters.vacation_active', 'Vacation Responder is active')}</Text>
<Text style={styles.vacationDesc}>{t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')}</Text>
</View>
</View>
)}
{rules.length > 0 && (
<View style={{ gap: 4 }}>
{rules.map((rule) => (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={styles.grip}>
<GripVertical size={16} color={c.mutedForeground} />
</View>
{!isOpaque && rules.length === 0 && !showVacationBanner && (
<View style={styles.emptyState}>
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>{t('settings.filters.no_rules', 'No filter rules')}</Text>
</View>
)}
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
{!isOpaque && rules.length > 0 && (
<View style={{ gap: spacing.sm }}>
{rules.map((rule) => {
const readonly = isReadonlyRule(rule);
const editIdx = editableIds.indexOf(rule.id);
<View style={{ flex: 1 }}>
<Text style={styles.ruleName}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
{rule.summary} → {rule.action}
</Text>
</View>
{deleteId === rule.id ? (
<View style={{ flexDirection: 'row', gap: 4 }}>
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
Confirm
</Button>
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
Cancel
</Button>
if (readonly) {
const label = rule.originLabel || t('settings.filters.origin_external', 'External');
const hasStructured = rule.origin === 'external' && rule.conditions.length > 0 && rule.actions.length > 0;
return (
<View key={rule.id} style={styles.ruleRow}>
<Lock size={16} color={c.mutedForeground} style={{ marginTop: 2 }} />
<View style={{ flex: 1, minWidth: 0 }}>
<View style={styles.ruleNameRow}>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
</View>
{hasStructured ? (
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
{summarizeRule(rule, t)}
</Text>
) : rule.rawBlock ? (
<Text style={styles.rawBlock} numberOfLines={expandedView ? undefined : 4}>
{rule.rawBlock.trim()}
</Text>
) : null}
</View>
</View>
) : (
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
);
}
return (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={{ paddingTop: 2 }}>
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} />
</View>
<Pressable
style={{ flex: 1, minWidth: 0 }}
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
{summarizeRule(rule, t)}
</Text>
</Pressable>
<View style={styles.reorderCol}>
<Pressable
onPress={() => moveRule(rule.id, -1)}
disabled={editIdx <= 0}
hitSlop={6}
style={[styles.reorderBtn, editIdx <= 0 && styles.reorderBtnDisabled]}
>
<ChevronUp size={16} color={c.mutedForeground} />
</Pressable>
<Pressable
onPress={() => moveRule(rule.id, 1)}
disabled={editIdx === editableIds.length - 1}
hitSlop={6}
style={[styles.reorderBtn, editIdx === editableIds.length - 1 && styles.reorderBtnDisabled]}
>
<ChevronDown size={16} color={c.mutedForeground} />
</Pressable>
</View>
<Pressable onPress={() => handleDelete(rule)} hitSlop={6} style={styles.deleteBtn}>
<X size={16} color={c.mutedForeground} />
</Pressable>
)}
</View>
))}
</View>
);
})}
</View>
)}
</SettingsSection>
<View style={styles.footer}>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<View style={styles.footerBtns}>
{!isOpaque && (
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
Add Rule
<Button
variant="outline"
size="sm"
icon={<Plus size={14} color={c.text} />}
onPress={() => { setEditingRule(undefined); setShowRuleModal(true); }}
>
{t('settings.filters.add_rule', 'Add Rule')}
</Button>
)}
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
Raw Editor
<Button
variant="outline"
size="sm"
icon={<Code size={14} color={c.text} />}
onPress={() => setShowSieveEditor(true)}
>
{t('settings.filters.raw_editor', 'Raw Sieve Editor')}
</Button>
</View>
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>Expanded view</Text>
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
</View>
)}
<View style={styles.footerRight}>
{isSaving && <ActivityIndicator size="small" color={c.mutedForeground} />}
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text>
<ToggleSwitch checked={expandedView} onChange={(v) => updateSetting('filtersExpandedView', v)} />
</View>
)}
</View>
</View>
<FilterRuleModal
visible={showRuleModal}
rule={editingRule}
mailboxes={mailboxes}
onSave={handleSaveRule}
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
/>
<SieveEditorSheet
visible={showSieveEditor}
content={rawScript}
onSave={handleSaveSieve}
onClose={() => setShowSieveEditor(false)}
onValidate={handleValidate}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
ruleDisabled: { opacity: 0.6 },
grip: { paddingTop: 2 },
ruleName: { ...typography.bodyMedium, color: c.text },
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: c.mutedForeground },
});
statusText: { ...typography.body, color: c.mutedForeground, paddingVertical: spacing.md },
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.md },
opaqueBanner: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
linkPrimary: { ...typography.caption, color: c.primary, fontWeight: '500' },
linkDanger: { ...typography.caption, color: c.error, fontWeight: '500' },
linkMuted: { ...typography.caption, color: c.mutedForeground },
vacationBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.success,
backgroundColor: c.successBg,
},
vacationIcon: {
width: 32, height: 32, borderRadius: radius.full,
alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(34,197,94,0.15)',
},
vacationTitle: { ...typography.bodyMedium, color: c.success },
vacationDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
},
ruleDisabled: { opacity: 0.6 },
ruleNameRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
ruleName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
rawBlock: {
...typography.caption,
color: c.mutedForeground,
fontFamily: 'monospace',
marginTop: spacing.sm,
padding: spacing.sm,
backgroundColor: c.muted,
borderRadius: radius.xs,
},
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
reorderCol: { alignItems: 'center' },
reorderBtn: { padding: 2 },
reorderBtnDisabled: { opacity: 0.25 },
deleteBtn: {
width: 28, height: 28,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
footerBtns: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
footerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: c.mutedForeground },
});
}
+82 -15
View File
@@ -1,10 +1,12 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore, type DateFormat, type TimeFormat } from '../../stores/settings-store';
import { formatListDate } from '../../lib/date-format';
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
export function LanguageSettings() {
@@ -12,15 +14,30 @@ export function LanguageSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const override = useLocaleStore((s) => s.override);
const setOverride = useLocaleStore((s) => s.setOverride);
const locale = useLocaleStore((s) => s.locale);
const hydrated = useLocaleStore((s) => s.hydrated);
const hydrate = useLocaleStore((s) => s.hydrate);
const t = useLocaleStore((s) => s.t);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const hydrateSettings = useSettingsStore((s) => s.hydrate);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const update = useSettingsStore((s) => s.updateSetting);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
useEffect(() => { if (!settingsHydrated) void hydrateSettings(); }, [settingsHydrated, hydrateSettings]);
const deviceCode = detectDeviceLocale();
const selected: LocaleCode | 'system' = override ?? 'system';
// Build live preview samples so the user sees what each format looks like.
const now = new Date();
const fmtOpts = { dateFormat, timeFormat, locale };
const previewToday = formatListDate(now, fmtOpts);
const previewWeek = formatListDate(new Date(now.getTime() - 3 * 86400000), fmtOpts);
const previewOlder = formatListDate(new Date(now.getTime() - 40 * 86400000), fmtOpts);
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
const active = selected === code;
return (
@@ -42,21 +59,61 @@ export function LanguageSettings() {
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
return (
<SettingsSection
title={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
>
<SettingItem
label={t('settings.appearance.language.label', 'Language')}
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
noBorder
/>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View>
</SettingsSection>
>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View>
</SettingsSection>
<SettingsSection
title={t('settings.language_region.date_format.label', 'Date Format')}
description={t('settings.language_region.date_format.description', 'How dates are shown in the email list')}
>
<RadioGroup
value={dateFormat}
onChange={(v) => update('dateFormat', v as DateFormat)}
options={[
{ value: 'smart', label: t('settings.language_region.date_format.smart', 'Smart (locale-aware)') },
{ value: 'relative', label: t('settings.language_region.date_format.relative', 'Relative (1h ago, 2d ago)') },
{ value: 'full', label: t('settings.language_region.date_format.full', 'Always full date') },
]}
/>
<View style={styles.previewBox}>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_today', 'Today:')}</Text>
<Text style={styles.previewValue}>{previewToday}</Text>
</View>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_this_week', 'This week:')}</Text>
<Text style={styles.previewValue}>{previewWeek}</Text>
</View>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_older', 'Older:')}</Text>
<Text style={styles.previewValue}>{previewOlder}</Text>
</View>
</View>
</SettingsSection>
<SettingsSection
title={t('settings.language_region.time_format.label', 'Time Format')}
description={t('settings.language_region.time_format.description', 'Choose between 12-hour or 24-hour clock')}
>
<RadioGroup
value={timeFormat}
onChange={(v) => update('timeFormat', v as TimeFormat)}
options={[
{ value: '24h', label: t('settings.language_region.time_format.24h', '24-hour') },
{ value: '12h', label: t('settings.language_region.time_format.12h', '12-hour') },
]}
/>
</SettingsSection>
</View>
);
}
@@ -74,5 +131,15 @@ function makeStyles(c: ThemePalette) {
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
previewBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
gap: 4,
},
previewRow: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md },
previewLabel: { ...typography.caption, color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
});
}
+81 -3
View File
@@ -1,10 +1,18 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, RadioGroup } from './settings-section';
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
import { SettingsSection, RadioGroup, Select } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
ALL_QUICK_ACTIONS,
type SwipeAction,
type SwipeMode,
type QuickAction,
} from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
reply: 'Reply',
replyAll: 'Reply All',
forward: 'Forward',
delete: 'Delete',
archive: 'Archive',
markUnread: 'Mark Read/Unread',
star: 'Star',
move: 'Move to folder',
spam: 'Spam',
tag: 'Tag',
};
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
value,
label: QUICK_ACTION_LABELS[value],
}));
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -31,11 +57,27 @@ export function LayoutSettings() {
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const update = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
// Pick an action for a bar slot. If the chosen action already occupies
// another slot, swap the two so the three slots stay unique.
const setQuickActionSlot = (index: number, value: QuickAction) => {
const next = [...bottomActions];
const existing = next.indexOf(value);
if (existing !== -1 && existing !== index) {
next[existing] = next[index];
}
next[index] = value;
update('bottomQuickActions', next);
};
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
@@ -81,6 +123,34 @@ export function LayoutSettings() {
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<MoveVertical size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Reader quick actions</Text>
</View>
<Text style={styles.rowDescription}>
The three buttons shown between Prev/Next at the bottom of an open
email. Defaults to Reply, Reply All and Forward.
</Text>
{bottomActions.map((action, index) => (
<View key={index} style={styles.slotRow}>
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
<Select
value={action}
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
options={QUICK_ACTION_OPTIONS}
/>
</View>
))}
{relocated.length > 0 && (
<Text style={styles.rowDescription}>
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
you can still use {relocated.length === 1 ? 'it' : 'them'}.
</Text>
)}
</View>
</SettingsSection>
);
}
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
slotRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: spacing.xs,
},
slotLabel: { ...typography.body, color: c.text },
});
}
@@ -83,6 +83,7 @@ export function ReadingSettings() {
const showPreview = useSettingsStore((s) => s.showPreview);
const mailLayout = useSettingsStore((s) => s.mailLayout);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
@@ -177,6 +178,7 @@ export function ReadingSettings() {
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'trash-and-read', label: 'Move to Trash and mark as read' },
{ value: 'permanent', label: 'Permanently delete' },
]}
/>
@@ -250,6 +252,10 @@ export function ReadingSettings() {
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>
<SettingItem label="Include Group Inboxes" description="Also show group and shared inboxes in the unified All Inboxes view.">
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem>
+29
View File
@@ -7,6 +7,9 @@ import {
normalizeAllDayDuration,
buildAllDayDuration,
getCalendarColor,
getEventStartDate,
getEventEndDate,
timePattern,
layoutOverlappingEvents,
CALENDAR_COLOR_PALETTE,
} from '../calendar-utils';
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
});
});
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
// floating `start` + duration instead of producing an Invalid Date, which
// would silently drop the event from the day index and every view.
it('falls back to start when utcStart is unparseable', () => {
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
expect(isNaN(d.getTime())).toBe(false);
expect(d.getHours()).toBe(9);
});
it('falls back to start + duration when utcEnd is unparseable', () => {
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
expect(isNaN(end.getTime())).toBe(false);
const start = getEventStartDate(ev({}));
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
});
});
describe('timePattern', () => {
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
expect(timePattern('12h')).toBe('h:mm a');
expect(timePattern('24h')).toBe('HH:mm');
expect(timePattern(undefined)).toBe('HH:mm');
});
});
describe('eventTimeRange', () => {
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
const range = eventTimeRange(ev({
+13 -2
View File
@@ -18,11 +18,22 @@ describe('buildInitialHtml', () => {
receivedAt: '2026-04-27T10:00:00Z',
});
expect(html).toContain('<p><br></p>');
expect(html).toContain('Alice &lt;a@b.com&gt; wrote:');
// Default header labels the sender by name only (matches webmail #295).
expect(html).toMatch(/On .+, Alice wrote:/);
expect(html).not.toContain('a@b.com');
expect(html).toContain('<blockquote');
expect(html).toContain('line one<br>line two');
});
it('falls back to the email address when the sender has no name', () => {
const html = buildInitialHtml('reply', {
from: { email: 'a@b.com' },
body: 'hi',
receivedAt: '2026-04-27T10:00:00Z',
});
expect(html).toContain('a@b.com wrote:');
});
it('uses HTML body when present, stripping dangerous tags', () => {
const html = buildInitialHtml('reply', {
from: { email: 'x@y.com' },
@@ -41,7 +52,7 @@ describe('buildInitialHtml', () => {
receivedAt: '2026-04-27T10:00:00Z',
});
expect(html).toContain('Forwarded message');
expect(html).toContain('From: Bob &lt;b@c.com&gt;');
expect(html).toContain('From: Bob');
expect(html).toContain('Subject: Original Subject');
expect(html).toContain('forwarded body');
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatListDate } from '../date-format';
afterEach(() => {
vi.useRealTimers();
});
describe('formatListDate', () => {
it('returns empty string for an invalid date', () => {
expect(formatListDate('not-a-date', { dateFormat: 'smart', timeFormat: '24h', locale: 'en' })).toBe('');
});
it('relative: minutes / hours / days ago', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const opts = { dateFormat: 'relative' as const, timeFormat: '24h' as const, locale: 'en' };
expect(formatListDate(new Date(Date.now() - 5 * 60000), opts)).toBe('5m ago');
expect(formatListDate(new Date(Date.now() - 3 * 3600000), opts)).toBe('3h ago');
expect(formatListDate(new Date(Date.now() - 2 * 86400000), opts)).toBe('2d ago');
});
it('relative: "Just now" under a minute', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
expect(
formatListDate(new Date(Date.now() - 10_000), { dateFormat: 'relative', timeFormat: '24h', locale: 'en' }),
).toBe('Just now');
});
it('smart: same-day shows time only (24h, no AM/PM)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const out = formatListDate(new Date(), { dateFormat: 'smart', timeFormat: '24h', locale: 'en' });
expect(out).not.toMatch(/AM|PM/);
expect(out).toMatch(/^\d{1,2}:\d{2}$/);
});
it('smart: older than a week shows a numeric date', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const out = formatListDate(new Date('2026-01-01T09:00:00Z'), {
dateFormat: 'smart',
timeFormat: '24h',
locale: 'en',
});
expect(out).toContain('2026');
});
it('full: always includes the year', () => {
const out = formatListDate('2026-04-28T15:31:00Z', { dateFormat: 'full', timeFormat: '24h', locale: 'en' });
expect(out).toContain('2026');
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import {
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
DEFAULT_EMAIL_TEMPLATE,
} from '../download-filename';
const SAMPLE = buildSampleEmail();
describe('emailExportFilename', () => {
it('renders the default template and appends .eml', () => {
const name = emailExportFilename(SAMPLE, DEFAULT_EMAIL_TEMPLATE);
expect(name.endsWith('.eml')).toBe(true);
expect(name).toContain('2026-05-22');
expect(name).toContain('Alice');
expect(name).toContain('Bob');
});
it('falls back to "email.eml" when the template renders empty', () => {
expect(emailExportFilename(SAMPLE, '')).toBe('email.eml');
});
it('applies the lowercase + underscore transforms', () => {
const name = emailExportFilename(SAMPLE, {
template: '{from} {subject}',
lowercase: true,
spaceReplacement: 'underscore',
});
expect(name).toBe(name.toLowerCase());
expect(name).not.toContain(' ');
});
it('strips diacritics when requested', () => {
const name = emailExportFilename(SAMPLE, {
template: '{subject}',
stripDiacritics: true,
});
// "Gerät" → "Gerat"
expect(name).toContain('Gerat');
expect(name).not.toContain('ä');
});
});
describe('attachmentDownloadFilename', () => {
const attachment = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
it('keeps the original name with the {filename} template', () => {
const name = attachmentDownloadFilename(SAMPLE, attachment, '{filename}');
expect(name).toBe('Rechnung 2026.pdf');
});
it('re-appends the extension when the template omits it', () => {
const name = attachmentDownloadFilename(SAMPLE, attachment, '{date_short} {name}');
expect(name.endsWith('.pdf')).toBe(true);
expect(name).toContain('2026-05-22');
});
it('falls back to the raw name when no email is provided', () => {
const name = attachmentDownloadFilename(null, attachment);
expect(name).toBe('Rechnung 2026.pdf');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml } from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
function extractScript(html: string): string {
const m = html.match(/<script>([\s\S]*?)<\/script>/);
expect(m, 'page must contain an inline <script>').toBeTruthy();
return m![1];
}
describe('buildEditorHtml', () => {
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
// Regression for issue #11: backslash escapes inside the template literal
// are cooked away (`\/` → `/`), which once emitted an invalid regex. That
// SyntaxError killed the entire inline script, so the editor never posted
// `change` messages and Send stayed disabled on Android/iOS.
it('emits an inline script that parses as valid JavaScript', () => {
for (const c of [LIGHT_COLORS, DARK_COLORS]) {
const script = extractScript(buildEditorHtml({ ...base, c }));
// new Function() parses without executing (script needs a DOM to run).
expect(() => new Function(script)).not.toThrow();
}
});
it('emits no cooked-away backslashes in the script', () => {
// The page script is written escape-free on purpose; a backslash-free
// source cannot silently lose escapes to template-literal cooking.
const script = extractScript(buildEditorHtml(base));
expect(script).not.toContain('\\');
});
it('embeds initial content and placeholder as JSON strings', () => {
const html = buildEditorHtml({
...base,
initialHtml: '<p>He said "hi" & left</p>',
placeholder: 'Say "something"…',
});
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
});
it('keeps the editor contenteditable and the bridge object wiring', () => {
const html = buildEditorHtml(base);
expect(html).toContain('<div id="editor" contenteditable="true"');
const script = extractScript(html);
expect(script).toContain('window.__rne');
expect(script).toContain('ReactNativeWebView.postMessage');
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSecureFetch } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
}));
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
}));
import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth';
describe('oauth token refresh deduplication', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => {
const tokens: OAuthTokens = {
accessToken: 'old-access',
refreshToken: 'same-refresh-token',
expiresAt: 123456,
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-id-123',
};
// Set up a mock response that takes a brief moment to resolve
mockSecureFetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return {
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'rotated-refresh-token',
expires_in: 3600,
}),
} as Response;
});
// Fire two refreshes concurrently
const [res1, res2] = await Promise.all([
refreshOAuthAccessToken(tokens),
refreshOAuthAccessToken(tokens),
]);
// Check that secureFetch was called exactly once
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
// Verify both calls received the exact same updated token bundle
expect(res1.accessToken).toBe('new-access-token');
expect(res1.refreshToken).toBe('rotated-refresh-token');
expect(res2.accessToken).toBe('new-access-token');
expect(res2.refreshToken).toBe('rotated-refresh-token');
// Fire another refresh after the first completes (now the map should be empty for this token)
mockSecureFetch.mockClear();
const res3 = await refreshOAuthAccessToken(tokens);
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
expect(res3.accessToken).toBe('new-access-token');
});
it('should propagate errors to all waiters if the HTTP request fails', async () => {
const tokens: OAuthTokens = {
accessToken: 'old-access',
refreshToken: 'failing-refresh-token',
expiresAt: 123456,
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-id-123',
};
mockSecureFetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return {
ok: false,
status: 400,
} as Response;
});
// Fire two refreshes concurrently and check that both reject with the same error
const promise1 = refreshOAuthAccessToken(tokens);
const promise2 = refreshOAuthAccessToken(tokens);
await expect(promise1).rejects.toThrow('Token refresh failed: 400');
await expect(promise2).rejects.toThrow('Token refresh failed: 400');
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Provide the Android native FCM surface setupPushNotifications needs. The
// global test-setup mocks react-native with an empty NativeModules, so override
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
// the runtime permission request).
vi.mock('react-native', () => {
class NativeEventEmitter {
addListener() {
return { remove: () => undefined };
}
}
return {
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
NativeModules: {
BulwarkFcm: {
getToken: vi.fn(async () => 'fcm-token-xyz'),
deleteToken: vi.fn(async () => undefined),
},
},
NativeEventEmitter,
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
};
});
vi.mock('../../api/jmap-client', () => ({
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
}));
vi.mock('../../api/push', () => ({
listPushSubscriptions: vi.fn(async () => []),
createPushSubscription: vi.fn(async () => 'new-server-id'),
verifyPushSubscription: vi.fn(async () => undefined),
destroyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async () => true),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
import {
listPushSubscriptions,
createPushSubscription,
destroyPushSubscription,
} from '../../api/push';
import { generateAccountId } from '../account-utils';
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
const RELAY = 'https://relay.example.com';
// State the fake relay reports for each foreign deviceClientId's /active probe.
type RelayState = 'dead' | 'live' | 'unknown';
function installFetch(states: Record<string, RelayState>): void {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
if (active) {
const dcid = decodeURIComponent(active[1]);
const state = states[dcid] ?? 'unknown';
if (state === 'unknown') {
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
}
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
}
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
function sub(id: string, deviceClientId: string) {
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
}
describe('setupPushNotifications leftover reaping', () => {
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
// Pin our deviceClientId so we control which leftovers are "ours".
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
});
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
listMock.mockResolvedValue([
sub('own-old', OUR_DCID), // our own previous attempt -> reap
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
]);
installFetch({
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
livelivelivelivelivelivelivelive: 'live',
unknwunknwunknwunknwunknwunknwun: 'unknown',
});
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.verified).toBe(true);
const reaped = destroyMock.mock.calls.map((c) => c[0]);
expect(reaped).toContain('own-old');
expect(reaped).toContain('foreign-dead');
expect(reaped).not.toContain('foreign-live');
expect(reaped).not.toContain('foreign-unknown');
});
it('keeps foreign subs when the relay probe fails (network error)', async () => {
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
if (url.includes('/api/push/active/')) throw new Error('network down');
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
});
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
listMock.mockResolvedValue([]);
installFetch({});
// Fire several overlapping setups, as App.tsx does while auth settles and
// on FCM token refresh. Only one underlying JMAP subscription must be made.
const results = await Promise.all([
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
]);
expect(createMock).toHaveBeenCalledTimes(1);
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
});
});
@@ -174,3 +174,77 @@ describe('expandRecurringEvents - range filtering', () => {
]);
});
});
describe('expandRecurringEvents - excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = ev({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ frequency: 'daily' }],
excludedRecurrenceRules: [{
frequency: 'weekly',
byDay: [{ day: 'tu' }],
}],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map((e) => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
describe('expandRecurringEvents - long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = ev({
start: '2022-01-03T09:00:00',
recurrenceRules: [{ frequency: 'daily' }],
});
const result = expandRecurringEvents([event], '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(result.map((e) => e.start)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = ev({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ frequency: 'weekly' }],
});
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map((e) => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = ev({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ frequency: 'daily', count: 5 }],
});
// Range far after the 5 occurrences ran out
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
describe('expandRecurringEvents - event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = ev({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ frequency: 'weekly' }],
});
const result = expandRecurringEvents([event], '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map((e) => e.utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map((e) => e.utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
+73 -4
View File
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable — a malformed utcStart used to surface as an
// Invalid Date that silently dropped the event from every view (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start);
}
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = getEventStartDate(event);
if (!event.duration) return start;
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
return subMilliseconds(end, 1);
}
// ─── Time-of-day formatting ──────────────────────────────
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
// thread the `calendarTimeFormat` setting through so the calendar renders
// times in the same format the webmail does.
export type TimeFormat = '12h' | '24h';
export function timePattern(timeFormat: TimeFormat | undefined): string {
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
}
export interface EventTimeRange {
start: Date;
end: Date;
@@ -435,12 +453,63 @@ export function getEventColor(
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
calendars: Calendar[],
): string {
if (event.color) return event.color;
const calendarId = Object.keys(event.calendarIds || {})[0];
const cal = calendars.find((c) => c.id === calendarId);
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (cal?.colorIsLocalOverride && cal.color) return cal.color;
if (event.color) return event.color;
return getCalendarColor(cal);
}
// ─── Per-viewer colors for shared calendars (#345) ───────
// Shared calendars are recolored locally only: the viewer usually can't write
// the owner's calendar, and doing so would recolor it for everyone. Overrides
// live in the settings store keyed by sharedCalendarColorKey().
/**
* Stable key for a shared calendar's local color override. Built from the
* owning JMAP account + the calendar id so it stays unique across accounts.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'accountId'>,
): string {
return `${cal.accountId ?? ''}|${cal.id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_PALETTE.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_PALETTE;
return pool[Math.floor(Math.random() * pool.length)];
}
/**
* Apply each shared calendar's local color override (per-viewer recolor).
* The override replaces the calendar's color and wins over per-event colors
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
* are passed through untouched.
*/
export function applySharedCalendarColors(
calendars: Calendar[],
overrides: Record<string, string>,
): Calendar[] {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = overrides[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}
export function getPrimaryCalendarId(
event: Pick<CalendarEvent, 'calendarIds'>,
): string | undefined {
+45 -18
View File
@@ -3,6 +3,7 @@
// All values are HTML-escaped before insertion into the document.
import { escapeHtml, stripDangerousTags } from './email-html';
import type { TimeFormat } from '../stores/settings-store';
export interface ReplyMeta {
from?: { email?: string; name?: string };
@@ -14,35 +15,62 @@ export interface ReplyMeta {
receivedAt?: string;
}
export interface QuoteHeaderOptions {
timeFormat?: TimeFormat;
locale?: string;
unknownLabel?: string;
}
function escapeForHtmlBody(text: string): string {
return escapeHtml(text).replace(/\n/g, '<br>');
}
function senderLabel(from?: { email?: string; name?: string }): string {
if (!from) return '';
if (from.name && from.email) return `${from.name} <${from.email}>`;
return from.name || from.email || '';
// Webmail's default quote header labels the sender by display name when there
// is one, falling back to the bare address — not "Name <addr>".
function senderName(from?: { email?: string; name?: string }, unknownLabel = 'Unknown'): string {
if (!from) return unknownLabel;
return from.name || from.email || unknownLabel;
}
function formatDate(iso?: string): string {
// Mirror the webmail quote-header date: locale- and 12/24h-aware, with a short
// weekday (lib/quote-header.ts → formatDateTime with weekday/year/month/day).
function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString();
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
return d.toLocaleString(intlLocale, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: timeFormat === '12h',
});
}
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
/**
* Build the seed HTML for a new compose / reply / forward. Returns an empty
* paragraph for plain compose (so the contenteditable has a caret target).
*
* The reply/forward quote header mirrors the webmail default header
* (`lib/quote-header.ts`): a reply gets "On <date>, <sender> wrote:" above a
* blockquoted body; a forward gets a From/Date/Subject block followed by the
* original body (not blockquoted).
*/
export function buildInitialHtml(
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
reply?: ReplyMeta | null,
opts: QuoteHeaderOptions = {},
): string {
if (!reply || mode === 'compose') return '<p><br></p>';
const date = formatDate(reply.receivedAt);
const fromStr = senderLabel(reply.from) || 'Unknown';
const { timeFormat = '24h', locale, unknownLabel = 'Unknown' } = opts;
const date = formatQuoteDate(reply.receivedAt, timeFormat, locale);
const fromStr = senderName(reply.from, unknownLabel);
const subject = reply.subject ?? '';
const quotedBody = reply.htmlBody
@@ -54,27 +82,26 @@ export function buildInitialHtml(
if (!quotedBody) return '<p><br></p>';
if (mode === 'forward') {
// Forwarded message block (From/Date/Subject), body appended unquoted.
return [
'<p><br></p>',
'<div>---------- Forwarded message ----------<br>',
`From: ${escapeHtml(fromStr)}<br>`,
date ? `Date: ${escapeHtml(date)}<br>` : '',
subject ? `Subject: ${escapeHtml(subject)}<br>` : '',
reply.to && reply.to.length
? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}<br>`
: '',
`Date: ${escapeHtml(date)}<br>`,
`Subject: ${escapeHtml(subject)}<br><br>`,
'</div>',
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
quotedBody,
].join('');
}
// reply / replyAll
const headerLine = date
? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:`
: `${escapeHtml(fromStr)} wrote:`;
return [
'<p><br></p>',
'<div>',
`On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`,
'</div>',
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
`<div>${headerLine}<br></div>`,
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
].join('');
}
+88
View File
@@ -0,0 +1,88 @@
import type { DateFormat, TimeFormat } from '../stores/settings-store';
/**
* Formats a received-at date for the email list. Mirrors the webmail
* `formatDate` helper (lib/utils.ts) so both clients render list dates the
* same way. The style is controlled by the `dateFormat` user setting:
*
* - `smart` (default) — locale-aware, age-bucketed:
* today → time only ("15:31" or "3:31 PM")
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
* older → full locale date ("28.04.2026", "04/28/2026")
* - `relative` — relative format ("1h ago", "2d ago").
* - `full` — always the full locale date+time.
*
* `locale` is the language subtag from the locale store (e.g. "en", "de").
*/
export function formatListDate(
date: Date | string,
opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string },
): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return '';
const now = new Date();
const { dateFormat, timeFormat } = opts;
const localeRaw = opts.locale;
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : 'en';
// `en` alone resolves to en-US in Intl; everything else uses the language
// subtag as-is and lets the runtime pick a sensible default region.
const intlLocale = locale === 'en' ? 'en-US' : locale;
const hour12 = timeFormat === '12h';
if (dateFormat === 'relative') {
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(intlLocale, {
month: 'short',
day: 'numeric',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
}
if (dateFormat === 'full') {
return d.toLocaleString(intlLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12,
});
}
// 'smart' (default)
const timeStr = d.toLocaleTimeString(intlLocale, {
hour: '2-digit',
minute: '2-digit',
hour12,
});
const isSameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (isSameDay) return timeStr;
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
if (daysAgo < 7) {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = d
.toLocaleDateString(intlLocale, { weekday: 'short' })
.replace(/\.$/, '');
return `${weekday} ${timeStr}`;
}
return d.toLocaleDateString(intlLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
+234
View File
@@ -0,0 +1,234 @@
import type { Email } from '../api/types';
// Ported from the webmail `lib/download-filename.ts` so exported `.eml` files and
// downloaded attachments get the same templated, cross-platform-safe filenames.
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
// to `_`. Keeps filenames usable across Android/iOS/desktop.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
export interface FilenameTransformOptions {
spaceReplacement?: SpaceReplacement;
lowercase?: boolean;
stripDiacritics?: boolean;
collapseSeparators?: boolean;
}
export interface EmailFilenameOptions extends FilenameTransformOptions {
template?: string;
}
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
spaceReplacement: 'keep',
lowercase: false,
stripDiacritics: false,
collapseSeparators: true,
};
export const DEFAULT_EMAIL_TEMPLATE = '{date} ({from}-{to}) {subject}';
export const DEFAULT_ATTACHMENT_TEMPLATE = '{filename}';
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: 'date', description: 'Full date and time, e.g. 2026-05-22 14.05.33' },
{ token: 'date_short', description: 'Date only, e.g. 2026-05-22' },
{ token: 'time', description: 'Time only, e.g. 14.05.33' },
{ token: 'year', description: '4-digit year' },
{ token: 'month', description: '2-digit month' },
{ token: 'day', description: '2-digit day' },
{ token: 'from', description: 'Sender display name (falls back to email user part)' },
{ token: 'from_email', description: 'Sender full email address' },
{ token: 'from_name', description: 'Sender name only' },
{ token: 'to', description: 'First recipient display name' },
{ token: 'to_email', description: 'First recipient full email address' },
{ token: 'to_name', description: 'First recipient name only' },
{ token: 'subject', description: 'Email subject' },
];
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
...EMAIL_TOKENS,
{ token: 'filename', description: 'Original attachment filename including extension' },
{ token: 'name', description: 'Attachment filename without extension' },
{ token: 'ext', description: 'Attachment file extension without leading dot' },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, '_')
.replace(/_+/g, '_')
.replace(/\s+/g, ' ')
.trim()
.replace(/^[._-]+|[._-]+$/g, '');
return cleaned.slice(0, maxLen);
}
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
let s = input;
if (opts.stripDiacritics) {
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
// combining marks then leaves plain ASCII letters.
s = s.normalize('NFD').replace(/\p{M}+/gu, '');
}
const repl = opts.spaceReplacement ?? 'keep';
if (repl === 'underscore') s = s.replace(/ +/g, '_');
else if (repl === 'dash') s = s.replace(/ +/g, '-');
if (opts.collapseSeparators ?? true) {
s = s.replace(/_+/g, '_').replace(/-+/g, '-').replace(/ +/g, ' ');
}
if (opts.lowercase) s = s.toLocaleLowerCase();
return s.replace(/^[._\- ]+|[._\- ]+$/g, '');
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function dateParts(iso: string | null | undefined) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) {
return {
date: '0000-00-00 00.00.00',
date_short: '0000-00-00',
time: '00.00.00',
year: '0000',
month: '00',
day: '00',
};
}
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
return {
date: `${year}-${month}-${day} ${time}`,
date_short: `${year}-${month}-${day}`,
time,
year,
month,
day,
};
}
function addrLabel(addr: { name?: string | null; email?: string } | undefined): {
name: string;
email: string;
label: string;
} {
if (!addr) return { name: '', email: '', label: 'unknown' };
const name = (addr.name && addr.name.trim()) || '';
const email = addr.email || '';
const label = name || email.split('@')[0] || email || 'unknown';
return { name, email, label };
}
export function emailVars(email: Email): Record<string, string> {
const dp = dateParts(email.receivedAt || email.sentAt);
const from = addrLabel(email.from?.[0]);
const to = addrLabel(email.to?.[0]);
return {
...dp,
from: from.label,
from_email: from.email,
from_name: from.name,
to: to.label,
to_email: to.email,
to_name: to.name,
subject: email.subject || 'no subject',
};
}
export interface AttachmentLike {
name?: string | null;
type?: string | null;
}
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
const filename = (attachment.name || 'attachment').trim();
const dot = filename.lastIndexOf('.');
const hasExt = dot > 0 && dot < filename.length - 1;
const name = hasExt ? filename.slice(0, dot) : filename;
const ext = hasExt ? filename.slice(dot + 1) : '';
return {
...emailVars(email),
filename,
name,
ext,
};
}
function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return '';
return sanitizePart(value);
});
}
export function emailExportFilename(
email: Email,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === 'string' ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || 'email';
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === 'string' ? { template: options } : options;
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) {
const filename = (attachment.name || 'attachment').trim();
const cleaned = sanitizePart(filename, 200) || 'attachment';
return applyTransforms(cleaned, opts) || cleaned;
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return '';
// Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise).
return key === 'filename' ? value.replace(SAFE_CHARS, '_') : sanitizePart(value);
});
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || 'attachment';
if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned;
}
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
const ext = vars.ext;
if (!ext) return transformedStem;
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
return `${transformedStem}.${transformedExt}`;
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
const iso = '2026-05-22T14:05:33Z';
return {
id: 'sample-1',
threadId: 'sample-thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 12345,
receivedAt: iso,
sentAt: iso,
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
to: [{ name: 'Bob Recipient', email: 'bob@example.com' }],
cc: [],
subject: 'Benachrichtigung von Ihrem Gerät',
preview: '',
hasAttachment: true,
blobId: 'sample-blob',
} as Email;
}
+286
View File
@@ -0,0 +1,286 @@
// Builds the self-contained HTML document for the RichTextEditor WebView.
// Kept as a pure module (no react-native imports) so tests can assert the
// embedded page script stays syntactically valid JavaScript.
//
// IMPORTANT: the page script lives inside a JS template literal. Backslash
// escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`),
// which once shipped an invalid regex that killed the whole inline script
// with a SyntaxError (issue #11 — Send stayed disabled because the editor
// never posted `change` messages). Never rely on a backslash reaching the
// page verbatim; write escape-free code or double the backslash.
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
export const MIN_EDITOR_HEIGHT = 220;
export function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_EDITOR_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient. Written without regex
// backslash escapes — see the template-cooking warning at the top.
var trimmed = (url || '').trim();
var first = trimmed.charAt(0);
var safeScheme = /^(https?:|mailto:|tel:|sms:)/i.test(trimmed)
|| first === '/' || first === '?' || first === '#';
if (!trimmed || !safeScheme) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
+51 -6
View File
@@ -2,10 +2,38 @@ import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
import type { Email } from '../api/types';
import { useSettingsStore } from '../stores/settings-store';
import {
attachmentDownloadFilename,
emailExportFilename,
type EmailFilenameOptions,
} from './download-filename';
import { getClientCertAlias, secureFetch } from './client-cert';
const RFC822 = 'message/rfc822';
// Read the user's filename template + transform preferences for exports.
function emailFileOptions(): EmailFilenameOptions {
const s = useSettingsStore.getState();
return {
template: s.emailExportTemplate,
spaceReplacement: s.exportSpaceReplacement,
lowercase: s.exportLowercase,
stripDiacritics: s.exportStripDiacritics,
};
}
function attachmentFileOptions(): EmailFilenameOptions {
const s = useSettingsStore.getState();
return {
template: s.attachmentExportTemplate,
spaceReplacement: s.exportSpaceReplacement,
lowercase: s.exportLowercase,
stripDiacritics: s.exportStripDiacritics,
};
}
function authHeader(): string {
return jmapClient.authHeader;
}
@@ -60,11 +88,16 @@ export async function shareAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
email?: Email | null,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<void> {
const filename = safeAttachmentName(name, type);
const filename = email
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
: safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
@@ -84,11 +117,16 @@ export async function downloadAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
email?: Email | null,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<void> {
const filename = safeAttachmentName(name, type);
const filename = email
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
: safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.document);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
@@ -111,8 +149,15 @@ function safeFilename(subject: string | undefined): string {
return `${base}.eml`;
}
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
export async function shareEmailEml(
blobId: string,
email?: Email | null,
subjectFallback?: string,
): Promise<void> {
const filename = email
? emailExportFilename(email, emailFileOptions())
: safeFilename(subjectFallback);
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
import { File } from 'expo-file-system';
// Mirrors the webmail `lib/eml-import.ts`: turn picked files into a flat list of
// importable `.eml` messages, expanding `.zip` archives along the way. On RN the
// picker hands us file URIs (file:// or content://) rather than File objects, so
// we read bytes via expo-file-system and upload those.
export interface ImportableEml {
name: string;
bytes: Uint8Array;
}
export const EML_IMPORT_TYPES = [
'message/rfc822',
'application/zip',
'application/x-zip-compressed',
'application/octet-stream', // some pickers report .eml/.zip as octet-stream
];
function isEmlName(name: string): boolean {
return /\.eml$/i.test(name);
}
function isZip(name: string, mime?: string): boolean {
return /\.zip$/i.test(name) || mime === 'application/zip' || mime === 'application/x-zip-compressed';
}
async function extractEmlsFromZip(bytes: Uint8Array): Promise<ImportableEml[]> {
const { default: JSZip } = await import('jszip');
const zip = await JSZip.loadAsync(bytes);
const out: ImportableEml[] = [];
for (const entry of Object.values(zip.files)) {
if (entry.dir || !isEmlName(entry.name)) continue;
const data = await entry.async('uint8array');
out.push({ name: entry.name.split(/[\\/]/).pop() || entry.name, bytes: data });
}
return out;
}
/**
* Expand a single picked file (by URI) into its importable `.eml` messages.
* A `.eml` yields one entry; a `.zip` yields one per `.eml` it contains.
*/
export async function expandImportableEml(
uri: string,
name: string,
mime?: string,
): Promise<ImportableEml[]> {
const bytes = await new File(uri).bytes();
if (isZip(name, mime)) return extractEmlsFromZip(bytes);
return [{ name, bytes }];
}
+33
View File
@@ -96,3 +96,36 @@ export function flattenVisible(
walk(nodes);
return out;
}
export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'trash');
if (roleMatch) return roleMatch;
const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'archive');
if (roleMatch) return roleMatch;
const names = ['archive', 'archives', 'archived'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
if (roleMatch) return roleMatch;
const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
+39
View File
@@ -0,0 +1,39 @@
// Classify an error thrown by a JMAP call as a *transient* connectivity
// failure (server unreachable, offline, session not yet up) versus a
// *permanent* one (the server received the request and rejected it, e.g. a
// 4xx). The offline outbox uses this to decide whether to keep an operation
// queued for a later retry or to give up on it.
//
// We err on the side of "transient" only for signals we recognise as
// connectivity-related; anything else (including bare 4xx/5xx responses) is
// treated as permanent so a poison operation can't wedge the queue forever.
import { useNetworkStore } from '../stores/network-store';
const TRANSIENT_NAMES = new Set([
'NetworkError', // jmap-client transport wrapper
'AbortError', // request aborted (e.g. app backgrounded)
'TypeError', // RN fetch throws TypeError("Network request failed")
]);
const TRANSIENT_MESSAGE_HINTS = [
'network request failed',
'network error',
'failed to fetch',
'not connected',
'session expired', // token refresh will happen on the next live request
'timeout',
'timed out',
'connection',
];
export function isTransientNetworkError(err: unknown): boolean {
// If the device itself reports offline, treat any failure as transient.
if (!useNetworkStore.getState().online) return true;
if (!(err instanceof Error)) return false;
if (TRANSIENT_NAMES.has(err.name)) return true;
const msg = err.message?.toLowerCase() ?? '';
return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint));
}
+131 -30
View File
@@ -123,6 +123,92 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// QR login payloads. A QR scanned on the login screen either bootstraps the
// server URL for the normal webmail handoff (`connect`), or carries a one-time
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
// The payload never contains credentials — the `pair` code is redeemed for
// tokens over the network, once.
export type QrLoginPayload =
| { kind: 'connect'; webmailUrl: string }
| { kind: 'pair'; webmailUrl: string; code: string };
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
const trimmed = raw.trim();
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
if (match) {
const kind = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const server = params.get('server');
if (!server || !/^https?:\/\//i.test(server)) return null;
if (kind === 'pair') {
const code = params.get('code');
if (!code) return null;
return { kind: 'pair', webmailUrl: server, code };
}
return { kind: 'connect', webmailUrl: server };
}
// A bare https URL is treated as a server-bootstrap target so admins can
// hand out a plain webmail URL QR without the custom-scheme wrapper.
if (/^https?:\/\//i.test(trimmed)) {
return { kind: 'connect', webmailUrl: trimmed };
}
return null;
}
// Cross-device pairing redemption. Posts the scanned code to the webmail that
// minted it and maps the token bundle into the same shape the in-browser OAuth
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
const base = webmailUrl.replace(/\/+$/, '');
let response: Response;
try {
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ pairing_code: code }),
});
} catch {
throw new HandoffError('Could not reach the server to complete pairing');
}
if (!response.ok) {
// The code was unknown, already used, or expired (server returns 400) —
// or something else went wrong. Either way the user needs a fresh QR.
throw new HandoffError('Pairing code is invalid or has expired');
}
const data = (await response.json()) as {
server_url?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
token_endpoint?: string;
client_id?: string;
};
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
throw new HandoffError('Pairing response missing token material');
}
return {
flow: 'oauth',
serverUrl: data.server_url,
tokens: {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? undefined,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: data.token_endpoint,
clientId: data.client_id,
},
};
}
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
// OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can
// persist it.
@@ -130,37 +216,52 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
if (!tokens.refreshToken) {
throw new HandoffError('No refresh token available');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
const cacheKey = tokens.refreshToken;
let promise = activeRefreshes.get(cacheKey);
if (!promise) {
promise = (async () => {
try {
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken!,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken!,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
} finally {
activeRefreshes.delete(cacheKey);
}
})();
activeRefreshes.set(cacheKey, promise);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
return promise;
}
WebBrowser.maybeCompleteAuthSession();
+7
View File
@@ -30,6 +30,8 @@ const FETCH_CHUNK_FALLBACK = 25;
export interface RunOptions {
days: number;
/** Hard cap on the cache in megabytes; oldest mail is evicted to fit. */
maxMB?: number;
}
export async function runOfflineSync(opts: RunOptions): Promise<void> {
@@ -77,6 +79,7 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
cache.setSyncState({ completed: skipped });
if (toFetch.length === 0) {
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
cache.setSyncState({
phase: 'done',
finishedAt: Date.now(),
@@ -131,6 +134,10 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
cache.setSyncState({ completed, fetched, bytes });
}
// Trim the cache back under its size cap, shedding the oldest mail. Done
// after fetching so we never evict something we're about to keep.
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
cache.setSyncState({
phase: 'done',
completed: total,
+76 -15
View File
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
}
}
/**
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
* the relay positively reports it inactive - a record it knows about that has
* never forwarded a push and isn't freshly registered. Every other outcome (the
* relay doesn't recognise the id, an older relay without this endpoint, a
* network blip, or a live subscription) returns false, so we never reap
* anything we can't confirm is dead. This is what lets setup clear its own
* abandoned attempts - and dead siblings left by reinstalls that regenerated
* the deviceClientId - without disturbing another live device or the PWA that
* shares the account.
*/
async function relayReportsDead(
relayBaseUrl: string,
subscriptionId: string,
): Promise<boolean> {
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return false;
const body = (await res.json()) as { active?: unknown };
return body.active === false;
} catch {
return false;
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
@@ -241,12 +268,36 @@ async function pollVerificationCode(
throw new Error('Timed out waiting for PushVerification from JMAP server');
}
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
// effect as auth state settles during startup and again on every FCM token
// refresh; because a failing attempt blocks for up to 75s polling for the
// verification code, those re-fires overlap. Without this guard each
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
// PushVerification"). Callers share the first in-flight run instead.
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription.
* code, and finalise the subscription. Concurrent calls for the same account
* are coalesced onto a single in-flight run.
*/
export async function setupPushNotifications(
export function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
const existing = inFlightSetups.get(key);
if (existing) return existing;
const run = setupPushNotificationsInner(params).finally(() => {
inFlightSetups.delete(key);
});
inFlightSetups.set(key, run);
return run;
}
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this account's
// deviceClientId. These pile up when a previous enable attempt failed
// mid-flow (verify race, network blip, app killed). Stalwart per-account
// rate-limits PushVerification posts, so leaving stragglers around blocks
// the new one's verification - the symptom is a confusing "code does not
// match". Only reap matches for THIS account's deviceClientId; other
// accounts' subscriptions on this server (rare but possible if the same
// JMAP server backs multiple accounts) must be left alone.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await destroyPushSubscription(s.id).catch(() => undefined);
// Reap leftover Stalwart subscriptions that would otherwise starve the new
// one's verification. Stalwart emits only one PushVerification per account
// per ~60s and picks the oldest unverified subscription, so a single stale
// straggler blocks every fresh attempt - the symptom is the confusing
// "Timed out waiting for PushVerification" error. We can't read a
// subscription's verified state or URL over JMAP (Stalwart hides both), only
// its deviceClientId, so we decide what's safe to remove like this:
// - same deviceClientId as ours: a previous attempt from THIS device,
// always safe to reap.
// - a different deviceClientId: could be another live device or the PWA on
// this account. Ask the relay whether it's still alive and only reap the
// ones it confirms are dead. Anything live - or anything the relay can't
// vouch for (a different relay, a non-Bulwark client, a network blip) -
// is left untouched.
for (const s of existingSubs) {
if (s.id === storedServerId) continue;
if (s.deviceClientId === deviceClientId) {
await destroyPushSubscription(s.id).catch(() => undefined);
continue;
}
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
}
const serverAssignedId = await createPushSubscription({
+191 -27
View File
@@ -11,7 +11,7 @@
* on the client.
*/
import { addDays, addMonths, addWeeks, addYears, format, parseISO } from 'date-fns';
import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns';
import type { CalendarEvent, RecurrenceRule } from '../api/types';
type NDay = { day: string; nthOfPeriod?: number };
@@ -60,6 +60,10 @@ export function expandRecurringEvents(
return result;
}
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
@@ -73,13 +77,23 @@ function expandEvent(
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
// (iCalendar EXRULE) are removed from the recurrence set.
const excludedDates = new Set<string>();
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
const dateKey = occurrenceDateKey(master, date);
if (excludedDates.has(dateKey)) continue;
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -104,9 +118,9 @@ function expandEvent(
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
// Overrides take precedence over excludedRecurrenceRules (they re-add
// a concrete instance), so only dedupe against already-generated dates.
const dateKey = occurrenceDateKey(master, overrideDate);
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -116,6 +130,65 @@ function expandEvent(
return occurrences;
}
// Cache Intl formatters per IANA timezone id — constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
let formatter = tzFormatterCache.get(timeZone);
if (formatter === undefined) {
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
formatter = null;
}
tzFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
const map: Record<string, string> = {};
for (const part of formatter.formatToParts(new Date(instant))) {
if (part.type !== 'literal') map[part.type] = part.value;
}
const hour = map.hour === '24' ? 0 : Number(map.hour);
return Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
);
}
/**
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
function createOccurrence(
master: CalendarEvent,
date: Date,
@@ -130,16 +203,33 @@ function createOccurrence(
// getEventEndDate return the occurrence times rather than the master's.
let utcStart: string | undefined;
let utcEnd: string | undefined;
if (!master.showWithoutTime && master.utcStart && master.start) {
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (!master.showWithoutTime) {
let durationMs: number | null = null;
if (master.utcStart && master.utcEnd) {
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
if (!isNaN(ms)) durationMs = ms;
}
if (master.utcEnd) {
const masterUtcEnd = parseISO(master.utcEnd);
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
// Convert the occurrence's wall time using the event's own timezone, so
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (master.utcStart && master.start) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
}
@@ -207,6 +297,7 @@ function generateDates(
rawRule: RecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -214,21 +305,34 @@ function generateDates(
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
let current = new Date(eventStart);
// Without a `count` limit we can jump straight to the period containing
// the visible range. With one, every occurrence since the series start
// must be generated so it counts against `count`.
let current = rule.count
? new Date(eventStart)
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
const maxIterations = 2000;
// Cap on *emitted* (in-range) dates. This must not count occurrences
// before rangeStart, otherwise a series started long ago (e.g. a daily
// event from two years back) exhausts the budget before reaching the
// visible range and silently renders nothing.
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
if (until && current > until) break;
if (
current >= rangeEnd
&& rule.frequency !== 'yearly'
&& rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly'
) break;
// Stop once no candidate in this or a later period can fall before
// rangeEnd. Monthly/yearly candidates may precede the period anchor
// within the same month/year, so floor the anchor before comparing.
if (rule.frequency === 'monthly') {
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
} else if (rule.frequency === 'yearly') {
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
} else if (current >= rangeEnd) {
break;
}
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
@@ -238,7 +342,7 @@ function generateDates(
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
@@ -250,7 +354,7 @@ function generateDates(
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval);
if (current <= eventStart && iterations === 1) {
@@ -259,7 +363,7 @@ function generateDates(
}
// Spec rule 1: the start date-time is ALWAYS the first occurrence.
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
if (!dates.some((d) => d.getTime() === eventStart.getTime())) {
dates.unshift(eventStart);
@@ -505,6 +609,66 @@ function advancePeriod(
}
}
// ─── Fast-forward to the visible range ───────────────────
// Jump to the period just before the visible range in O(1), so long-running
// series don't burn the iteration budget on years of out-of-range periods.
// Only valid for rules without `count` (counted rules must enumerate every
// occurrence from the series start).
function fastForwardToRange(
eventStart: Date,
frequency: RecurrenceRule['frequency'],
interval: number,
rangeStart: Date,
): Date {
if (eventStart >= rangeStart) return new Date(eventStart);
let periods: number;
switch (frequency) {
case 'secondly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
break;
case 'minutely':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
break;
case 'hourly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
break;
case 'daily':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
break;
case 'weekly':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
break;
case 'monthly': {
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
+ (rangeStart.getMonth() - eventStart.getMonth());
periods = Math.floor(months / interval);
break;
}
case 'yearly':
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
break;
default:
return new Date(eventStart);
}
// Land one full period early so candidates inside the boundary period
// (which can precede the period anchor) are still generated.
periods = Math.max(0, periods - 1);
if (periods === 0) return new Date(eventStart);
switch (frequency) {
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
case 'daily': return addDays(eventStart, periods * interval);
case 'weekly': return addWeeks(eventStart, periods * interval);
case 'monthly': return addMonths(eventStart, periods * interval);
case 'yearly': return addYears(eventStart, periods * interval);
default: return new Date(eventStart);
}
}
function expandByDayInMonth(
year: number,
month: number,
+246
View File
@@ -0,0 +1,246 @@
/**
* Custom recurrence rule helpers for the event editor. Mobile port of
* webmail's components/calendar/recurrence-editor.tsx logic (rule detection,
* construction, and the human-readable summary).
*/
import { format } from 'date-fns';
import type { RecurrenceRule } from '../api/types';
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
export type MonthlyMode = 'day' | 'nth';
export type EndsMode = 'never' | 'on' | 'after';
export const EDITOR_FREQUENCIES: EditorFrequency[] = ['daily', 'weekly', 'monthly', 'yearly'];
// JSCalendar weekday ids in display order (week starts Monday, matching the
// firstDayOfWeek the built rules declare).
export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
export const UNIT_LABELS: Record<EditorFrequency, string> = {
daily: 'days',
weekly: 'weeks',
monthly: 'months',
yearly: 'years',
};
// 2024-01-01 is a Monday — used to render weekday names via date-fns.
export function weekdayName(day: string, style: 'long' | 'short' = 'long'): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return format(ref, style === 'long' ? 'EEEE' : 'EEE');
}
export function monthName(month: number): string {
return format(new Date(2024, month - 1, 1), 'LLLL');
}
const NTH_LABELS: Record<number, string> = {
1: 'first',
2: 'second',
3: 'third',
4: 'fourth',
[-1]: 'last',
};
export function nthLabel(nth: number): string {
return NTH_LABELS[nth] ?? String(nth);
}
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
export function getNthDay(rule: RecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case 'daily':
base = interval > 1 ? `Every ${interval} days` : 'Daily';
break;
case 'weekly':
base = interval > 1 ? `Every ${interval} weeks` : 'Weekly';
break;
case 'monthly':
base = interval > 1 ? `Every ${interval} months` : 'Monthly';
break;
case 'yearly':
base = interval > 1 ? `Every ${interval} years` : 'Yearly';
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === 'weekly' && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, 'short'))
.join(', ');
if (days) parts.push(`on ${days}`);
}
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(`in ${monthName(m)}`);
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(`on the ${nthLabel(nthDay.nth)} ${weekdayName(nthDay.day)}`);
} else if (rule.byMonthDay?.length) {
parts.push(`on day ${rule.byMonthDay[0]}`);
}
}
let summary = parts.join(' ');
if (rule.count) {
summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · until ${format(d, 'MMM d, yyyy')}`;
}
}
return summary;
}
export interface RecurrenceEditorValue {
frequency: EditorFrequency;
interval: number;
weekDays: string[];
monthlyMode: MonthlyMode;
monthDay: number;
nth: number;
nthDay: string;
month: number;
endsMode: EndsMode;
untilDate: string; // yyyy-MM-dd
count: number;
}
/** Build the editor's initial state from an existing rule (or defaults). */
export function editorValueFromRule(
rule: RecurrenceRule | null,
eventStart: Date,
): RecurrenceEditorValue {
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const nthDayInfo = rule ? getNthDay(rule) : null;
const weekDays = (() => {
if (rule?.frequency === 'weekly' && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
})();
const monthFromRule = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
const untilFromRule = (() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, 'yyyy-MM-dd');
}
const inAYear = new Date(eventStart);
inAYear.setFullYear(inAYear.getFullYear() + 1);
return format(inAYear, 'yyyy-MM-dd');
})();
return {
frequency: rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: 'weekly',
interval: rule?.interval || 1,
weekDays,
monthlyMode: nthDayInfo ? 'nth' : 'day',
monthDay: rule?.byMonthDay?.[0] && rule.byMonthDay[0] >= 1 && rule.byMonthDay[0] <= 31
? rule.byMonthDay[0]
: eventStart.getDate(),
nth: nthDayInfo && (nthDayInfo.nth === -1 || (nthDayInfo.nth >= 1 && nthDayInfo.nth <= 4))
? nthDayInfo.nth
: Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1),
nthDay: nthDayInfo && WEEKDAYS.includes(nthDayInfo.day) ? nthDayInfo.day : startDay,
month: monthFromRule >= 1 && monthFromRule <= 12 ? monthFromRule : eventStart.getMonth() + 1,
endsMode: rule?.count ? 'after' : rule?.until ? 'on' : 'never',
untilDate: untilFromRule,
count: rule?.count ?? 12,
};
}
/** Build the JSCalendar rule the editor state describes. */
export function buildRuleFromEditorValue(
value: RecurrenceEditorValue,
eventStart: Date,
): RecurrenceRule {
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const built: RecurrenceRule = {
'@type': 'RecurrenceRule',
frequency: value.frequency,
interval: Math.max(1, value.interval),
rscale: 'gregorian',
skip: 'omit',
firstDayOfWeek: 'mo',
};
if (value.endsMode === 'after') {
built.count = Math.max(1, value.count);
} else if (value.endsMode === 'on' && value.untilDate) {
built.until = `${value.untilDate}T23:59:59`;
}
if (value.frequency === 'weekly') {
const days = value.weekDays.length ? value.weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (value.frequency === 'monthly' || value.frequency === 'yearly') {
if (value.monthlyMode === 'nth') {
built.byDay = [{ day: value.nthDay, nthOfPeriod: value.nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, value.monthDay))];
}
if (value.frequency === 'yearly') {
built.byMonth = [String(value.month)];
}
}
return built;
}
@@ -0,0 +1,381 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '../types';
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'bw-1',
name: 'Bulwark Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('external rule preservation (issue #201)', () => {
describe('parser - external rule recognition', () => {
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
const rule = result.rules[0];
expect(rule.origin).toBe('external');
expect(rule.originLabel).toBe('Roundcube');
expect(rule.name).toBe('Archive Newsletters');
expect(rule.conditions[0]).toMatchObject({
field: 'header',
comparator: 'contains',
value: 'news',
headerName: 'List-Id',
});
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
});
it('labels rules near a Nextcloud marker comment', () => {
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('Nextcloud');
});
it('falls back to "External" label when no known marker is present', () => {
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('External');
});
it('parses anyof/allof conditions in external rules', () => {
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].matchType).toBe('any');
expect(result.rules[0].conditions).toHaveLength(2);
});
it('parses negated conditions (not header :is)', () => {
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.rules[0].conditions[0]).toMatchObject({
field: 'from',
comparator: 'not_is',
value: 'spam@x.com',
});
});
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
const rule = result.rules[0];
expect(rule.origin).toBe('opaque');
expect(rule.rawBlock).toContain('if header :value');
});
it('collects all external require tokens', () => {
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
const result = parseScript(script);
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
});
});
describe('parser - mixed Bulwark + external', () => {
it('returns Bulwark rules from metadata and external rules from the rest', () => {
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
const bulwarkScript = generateScript(bulwark);
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
const result = parseScript(mixedScript);
expect(result.isOpaque).toBe(false);
expect(result.rules.length).toBeGreaterThanOrEqual(2);
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
const externalParsed = result.rules.filter(r => r.origin === 'external');
expect(bulwarkParsed).toHaveLength(1);
expect(bulwarkParsed[0].name).toBe('Bulwark A');
expect(externalParsed).toHaveLength(1);
expect(externalParsed[0].originLabel).toBe('Nextcloud');
});
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
const script = generateScript(bulwark);
const result = parseScript(script);
// Only the metadata-sourced rule, no duplicate "external" entry.
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
});
it('does not duplicate a Bulwark rule whose values contain literal braces', () => {
// Regression for an issue where a value like "Report Domain: {x}" caused
// parseIfBlockToRule's naive indexOf('{') to point inside the value
// instead of at the body, so the if-block fell back to an opaque rule
// that escaped the bulwark-name dedup filter.
const bulwark = [
makeBulwarkRule({
name: 'DMARC reports',
matchType: 'any',
conditions: [
{ field: 'subject', comparator: 'contains', value: 'Report Domain: mydomain.com' },
{ field: 'subject', comparator: 'contains', value: 'Report Domain: {mydomain.com}' },
],
actions: [{ type: 'mark_read' }, { type: 'move', value: 'DMARC' }],
}),
];
const script = generateScript(bulwark);
const result = parseScript(script);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
expect(result.rules[0].name).toBe('DMARC reports');
});
});
describe('generator - external splice', () => {
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
const externalRule: FilterRule = {
id: 'ext-0',
name: 'External',
enabled: true,
matchType: 'all',
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
actions: [{ type: 'move', value: 'Lists' }],
stopProcessing: false,
origin: 'external',
originLabel: 'Nextcloud',
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
};
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
const script = generateScript(rules);
expect(script).toContain('# Rule: Bulwark Rule');
expect(script).toContain('# Nextcloud Mail');
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
const externalIdx = script.indexOf('# Nextcloud Mail');
expect(bulwarkIdx).toBeLessThan(externalIdx);
});
it('unions external requires into the top-level require line', () => {
const script = generateScript([makeBulwarkRule()], undefined, {
externalRequires: ['fileinto', 'imap4flags', 'body'],
});
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"imap4flags"');
expect(requireLine).toContain('"body"');
});
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
const bulwarkWithJunk: FilterRule = {
...makeBulwarkRule(),
origin: 'bulwark',
originLabel: 'shouldnotbehere',
rawBlock: 'shouldnotbehere',
};
const script = generateScript([bulwarkWithJunk]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0]).not.toHaveProperty('origin');
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
});
it('never writes external rules into metadata', () => {
const ext: FilterRule = {
id: 'ext-0',
name: 'Ext',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
actions: [{ type: 'keep' }],
stopProcessing: false,
origin: 'external',
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
};
const script = generateScript([makeBulwarkRule(), ext]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].name).toBe('Bulwark Rule');
});
});
describe('fixture: mixed-origins.sieve', () => {
const fixture = readFileSync(
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
'utf-8',
);
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
const result = parseScript(fixture);
expect(result.isOpaque).toBe(false);
expect(result.vacation).toBeUndefined();
const byOrigin = {
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
external: result.rules.filter(r => r.origin === 'external'),
opaque: result.rules.filter(r => r.origin === 'opaque'),
};
expect(byOrigin.bulwark).toHaveLength(2);
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
expect(byOrigin.opaque).toHaveLength(1);
const labels = byOrigin.external.map(r => r.originLabel);
expect(labels).toContain('Roundcube');
expect(labels).toContain('Nextcloud');
expect(labels).toContain('External');
const opaqueRule = byOrigin.opaque[0];
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
});
it('preserves unknown-Sieve content through save round-trip', () => {
const parsed = parseScript(fixture);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// The unparseable construct must appear verbatim in the regenerated script.
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
// Require tokens from the external content are preserved.
expect(regenerated).toContain('"relational"');
// Bulwark rules are still present.
expect(regenerated).toContain('# Rule: Archive newsletters');
});
});
describe('round-trip', () => {
it('preserves external rules through parse → generate → parse', () => {
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const firstParse = parseScript(initial);
expect(firstParse.rules).toHaveLength(2);
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
externalRequires: firstParse.externalRequires,
});
const secondParse = parseScript(regenerated);
expect(secondParse.rules).toHaveLength(2);
const names = secondParse.rules.map(r => r.name).sort();
expect(names).toContain('VIP');
});
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
const parsed = parseScript(initial);
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
expect(externalBefore).toHaveLength(1);
// Simulate a user edit - update the Bulwark rule name
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
const reparsed = parseScript(regenerated);
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
expect(externalAfter).toHaveLength(1);
expect(externalAfter[0].name).toBe('Untouchable');
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
describe('Nextcloud Mail marker regions', () => {
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const nextcloudScript = [
NEXTCLOUD_MARKER,
'require ["imap4flags", "fileinto"];',
NEXTCLOUD_MARKER,
'',
NEXTCLOUD_MARKER,
'# FILTER: [{"name":"Wetterwarnungen"}]',
'if header :contains "From" "weather@x.com" {',
' fileinto "Weather";',
'}',
'',
'# PayPal',
'if header :contains "From" "paypal.de" {',
' addflag "$paypal";',
'}',
NEXTCLOUD_MARKER,
'',
].join('\n');
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
const result = parseScript(nextcloudScript);
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
expect(nextcloud).toHaveLength(1);
expect(nextcloud[0].origin).toBe('opaque');
// Every marker that wrapped actual rule content survives in the rawBlock.
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
expect(markerCount).toBe(2);
// Inner if-blocks are not exposed as separate external rules.
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
const result = parseScript(nextcloudScript);
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
});
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
const script = [
NEXTCLOUD_MARKER,
'require ["fileinto"];',
NEXTCLOUD_MARKER,
'',
].join('\n');
const result = parseScript(script);
expect(result.rules).toHaveLength(0);
expect(result.externalRequires).toContain('fileinto');
});
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
const parsed = parseScript(initial);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// Both wrapping markers remain, in the right positions.
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
expect(firstMarker).toBeGreaterThanOrEqual(0);
expect(lastMarker).toBeGreaterThan(firstMarker);
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
expect(regenerated).toContain('# PayPal');
const reparsed = parseScript(regenerated);
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('does not emit duplicate "External rules" headers across repeated saves', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
let script = initial;
for (let i = 0; i < 3; i++) {
const parsed = parseScript(script);
script = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
}
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
expect(headerCount).toBe(1);
});
});
});
@@ -0,0 +1,44 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
@metadata:end */
require ["body", "copy", "fileinto", "imap4flags", "relational"];
# Rule: Archive newsletters
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
fileinto "Newsletters";
}
# Rule: Flag VIP senders
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
addflag "\\Flagged";
addflag "\\Seen";
}
# --- External rules (managed outside Bulwark) ---
# rule:[Finance - auto-file invoices]
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
fileinto :copy "Finance/Invoices";
keep;
}
# Nextcloud Mail - begin
# Filter installed by Nextcloud Mail app
if header :contains "Subject" "[Support]" {
fileinto "Support";
}
# Nextcloud Mail - end
# A handwritten rule without a tool-specific marker.
# Bulwark should recognize this as generic "External" and preserve it.
if not header :is "X-Spam-Status" "No" {
fileinto "Junk";
}
# A rule using a Sieve construct Bulwark's visual editor does not understand.
# It must survive round-trips verbatim, shown to the user as read-only.
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
fileinto "LowPriority";
stop;
}
+470
View File
@@ -0,0 +1,470 @@
import { describe, it, expect } from 'vitest';
import { generateScript } from '../generator';
import { parseScript } from '../parser';
import type { FilterRule } from '../types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('generateScript', () => {
it('outputs metadata and no require for empty rules', () => {
const script = generateScript([]);
expect(script).toContain('/* @metadata:begin');
expect(script).toContain('@metadata:end */');
expect(script).not.toContain('require');
});
it('embeds compact metadata JSON', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
expect(match).not.toBeNull();
const metadata = JSON.parse(match![1]);
expect(metadata.version).toBe(1);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].id).toBe('rule-1');
});
it('generates single rule with from/contains', () => {
const script = generateScript([makeRule()]);
expect(script).toContain('# Rule: Test Rule');
expect(script).toContain('if header :contains "From" "test@example.com"');
expect(script).toContain('fileinto "Archive";');
});
describe('condition fields', () => {
it('maps to field to "To" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
})]);
expect(script).toContain('header :contains "To" "me@x.com"');
});
it('maps cc field to "Cc" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
})]);
expect(script).toContain('header :is "Cc" "cc@x.com"');
});
it('maps subject field to "Subject" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
})]);
expect(script).toContain('header :contains "Subject" "hello"');
});
it('maps header field with custom headerName', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
})]);
expect(script).toContain('header :contains "X-Custom" "test"');
});
it('handles size greater_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
})]);
expect(script).toContain('size :over 1000000');
});
it('handles size less_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
})]);
expect(script).toContain('size :under 500');
});
it('handles body contains', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
})]);
expect(script).toContain('body :contains "keyword"');
expect(script).toContain('"body"');
});
it('handles body is', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
})]);
expect(script).toContain('body :is "exact"');
});
});
describe('comparators', () => {
it('generates not_contains with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
})]);
expect(script).toContain('not header :contains "From" "spam"');
});
it('generates not_is with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
})]);
expect(script).toContain('not header :is "From" "bad@x.com"');
});
it('generates starts_with as :matches with trailing *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
})]);
expect(script).toContain('header :matches "Subject" "Re:*"');
});
it('generates ends_with as :matches with leading *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
})]);
expect(script).toContain('header :matches "Subject" "*urgent"');
});
it('generates matches as :matches', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
})]);
expect(script).toContain('header :matches "From" "*@company.com"');
});
});
describe('match types', () => {
it('wraps multiple conditions with allof for matchType all', () => {
const script = generateScript([makeRule({
matchType: 'all',
conditions: [
{ field: 'from', comparator: 'contains', value: 'a' },
{ field: 'subject', comparator: 'contains', value: 'b' },
],
})]);
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
});
it('wraps multiple conditions with anyof for matchType any', () => {
const script = generateScript([makeRule({
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'x' },
{ field: 'to', comparator: 'contains', value: 'y' },
],
})]);
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
});
it('uses no wrapper for single condition', () => {
const script = generateScript([makeRule()]);
expect(script).not.toContain('allof');
expect(script).not.toContain('anyof');
});
});
describe('actions', () => {
it('generates move as fileinto', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
expect(script).toContain('fileinto "Spam";');
});
it('generates copy as fileinto :copy', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
expect(script).toContain('fileinto :copy "Backup";');
});
it('generates forward as redirect', () => {
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
expect(script).toContain('redirect "fwd@x.com";');
});
it('generates mark_read as addflag \\Seen', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('addflag "\\\\Seen";');
});
it('generates star as addflag \\Flagged', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $label:Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$label:Important";');
});
it('generates discard', () => {
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
expect(script).toContain('discard;');
});
it('generates reject with message', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
expect(script).toContain('reject "Go away";');
});
it('generates keep', () => {
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
expect(script).toContain('keep;');
});
it('generates stop', () => {
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
expect(script).toContain('stop;');
});
});
describe('stopProcessing', () => {
it('appends stop when stopProcessing is true', () => {
const script = generateScript([makeRule({ stopProcessing: true })]);
const ifBlock = script.slice(script.indexOf('if '));
expect(ifBlock).toContain('stop;');
});
it('does not duplicate stop if last action is stop', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toHaveLength(1);
});
it('does not append stop after discard', () => {
const script = generateScript([makeRule({
actions: [{ type: 'discard' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
it('does not append stop after reject', () => {
const script = generateScript([makeRule({
actions: [{ type: 'reject', value: 'No' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
});
describe('disabled rules', () => {
it('excludes disabled rules from Sieve code', () => {
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
expect(script).not.toContain('# Rule: Hidden');
expect(script).not.toContain('if header');
});
it('preserves disabled rules in metadata', () => {
const rules = [makeRule({ enabled: false })];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0].enabled).toBe(false);
});
it('handles mixed enabled and disabled rules', () => {
const rules = [
makeRule({ id: '1', name: 'Active', enabled: true }),
makeRule({ id: '2', name: 'Inactive', enabled: false }),
makeRule({ id: '3', name: 'Also Active', enabled: true }),
];
const script = generateScript(rules);
expect(script).toContain('# Rule: Active');
expect(script).not.toContain('# Rule: Inactive');
expect(script).toContain('# Rule: Also Active');
});
});
describe('require extensions', () => {
it('includes fileinto for move', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
expect(script).toContain('"fileinto"');
});
it('includes fileinto and copy for copy action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
expect(script).toContain('"copy"');
expect(script).toContain('"fileinto"');
});
it('includes imap4flags for mark_read', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for star', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for add_label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes reject for reject action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
expect(script).toContain('"reject"');
});
it('includes body extension for body conditions', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
})]);
expect(script).toContain('"body"');
});
it('deduplicates extensions', () => {
const script = generateScript([
makeRule({ id: '1', actions: [{ type: 'star' }] }),
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
]);
const matches = script.match(/"imap4flags"/g);
expect(matches).toHaveLength(1);
});
it('only considers enabled rules for requires', () => {
const script = generateScript([
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
]);
const requireLine = script.split('\n').find(l => l.startsWith('require'));
expect(requireLine).not.toContain('reject');
expect(requireLine).toContain('fileinto');
});
});
describe('escaping', () => {
it('escapes double quotes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
})]);
expect(script).toContain('say \\"hello\\"');
});
it('escapes backslashes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
})]);
expect(script).toContain('path\\\\to\\\\file');
});
it('escapes folder names in actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'My "Folder"' }],
})]);
expect(script).toContain('fileinto "My \\"Folder\\"";');
});
});
describe('round-trip', () => {
it('preserves rules through generate → parse cycle', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Rule A', enabled: true }),
makeRule({ id: '2', name: 'Rule B', enabled: false }),
makeRule({
id: '3',
name: 'Complex',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'boss' },
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
stopProcessing: true,
}),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
});
it('generates multiple rules in order', () => {
const rules = [
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
];
const script = generateScript(rules);
const firstIdx = script.indexOf('# Rule: First');
const secondIdx = script.indexOf('# Rule: Second');
expect(firstIdx).toBeLessThan(secondIdx);
});
describe('edge cases', () => {
it('skips rules with empty conditions', () => {
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
expect(script).not.toContain('# Rule: Empty');
expect(script).not.toContain('if ');
});
it('skips rules with empty actions', () => {
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
expect(script).not.toContain('# Rule: NoAction');
expect(script).not.toContain('if ');
});
it('uses X-Unknown for header field without headerName', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
})]);
expect(script).toContain('header :contains "X-Unknown" "test"');
});
it('handles all enabled rules with different extensions combined', () => {
const rules = [
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
];
const script = generateScript(rules);
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"reject"');
expect(requireLine).toContain('"body"');
expect(requireLine).toContain('"imap4flags"');
});
it('generates no require line when only keep/discard/stop/forward actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
})]);
expect(script).not.toContain('require');
});
it('handles multiple actions on same rule', () => {
const script = generateScript([makeRule({
actions: [
{ type: 'move', value: 'Folder' },
{ type: 'mark_read' },
{ type: 'star' },
{ type: 'stop' },
],
})]);
expect(script).toContain('fileinto "Folder";');
expect(script).toContain('addflag "\\\\Seen";');
expect(script).toContain('addflag "\\\\Flagged";');
expect(script).toContain('stop;');
});
it('generates valid script for all-disabled rules', () => {
const rules = [
makeRule({ id: '1', enabled: false }),
makeRule({ id: '2', enabled: false }),
];
const script = generateScript(rules);
expect(script).toContain('@metadata:begin');
expect(script).not.toContain('if ');
expect(script).not.toContain('require');
});
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '../types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('parseScript', () => {
it('extracts rules from valid metadata', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('parses external rules when no Bulwark metadata is present', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBe('external');
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
expect(result.externalRequires).toContain('fileinto');
});
it('returns isOpaque for corrupted JSON', () => {
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for version mismatch', () => {
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for empty metadata block', () => {
const script = '/* @metadata:begin\n\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for missing rules array', () => {
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for invalid rule objects', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('handles metadata with extra whitespace', () => {
const rules = [makeRule()];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('handles script with only metadata block', () => {
const rules = [makeRule({ enabled: false })];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing end marker', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
});
it('treats an empty string as an empty, editable script (not opaque)', () => {
const result = parseScript('');
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
describe('round-trip', () => {
it('preserves complex rules through generate → parse', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
makeRule({
id: '2',
name: 'VIP',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
}),
makeRule({ id: '3', name: 'Disabled', enabled: false }),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('preserves rules with special characters', () => {
const rules = [makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
actions: [{ type: 'move', value: 'My "Folder"' }],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('preserves all action types', () => {
const rules = [makeRule({
actions: [
{ type: 'move', value: 'Folder' },
{ type: 'copy', value: 'Backup' },
{ type: 'forward', value: 'fwd@x.com' },
{ type: 'mark_read' },
{ type: 'star' },
{ type: 'add_label', value: 'Tag' },
{ type: 'keep' },
],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
});
describe('validation edge cases', () => {
it('returns isOpaque when rule has non-string id', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when rule has non-boolean enabled', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when rule has invalid matchType', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when condition missing value', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when action missing type', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('accepts valid empty rules array', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
it('preserves all comparator types through round-trip', () => {
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
const rules = comparators.map((comparator, i) => makeRule({
id: `r${i}`,
name: `Rule ${comparator}`,
conditions: [{ field: 'from', comparator, value: 'test' }],
}));
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('preserves size comparators through round-trip', () => {
const rules = [
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('preserves header field with custom headerName', () => {
const rules = [makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import type {
FilterRule,
FilterCondition,
FilterAction,
FilterMetadata,
VacationSieveConfig,
} from './types';
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
// script with a leading `@metadata` JSON comment that lets the parser round-trip
// the visual rules losslessly. Keep this in sync with the web generator so a
// script saved on one client reads back cleanly on the other.
const HEADER_MAP: Record<string, string> = {
from: 'From',
to: 'To',
cc: 'Cc',
subject: 'Subject',
};
function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
if (field === 'size') {
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`;
}
if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`;
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field];
const escaped = escapeString(value);
switch (comparator) {
case 'contains':
return `header :contains "${headerName}" "${escaped}"`;
case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`;
case 'is':
return `header :is "${headerName}" "${escaped}"`;
case 'not_is':
return `not header :is "${headerName}" "${escaped}"`;
case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`;
case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`;
case 'matches':
return `header :matches "${headerName}" "${escaped}"`;
default:
return `header :contains "${headerName}" "${escaped}"`;
}
}
function generateActions(actions: FilterAction[]): string[] {
return actions.map(action => {
switch (action.type) {
case 'move':
return `fileinto "${escapeString(action.value || '')}";`;
case 'copy':
return `fileinto :copy "${escapeString(action.value || '')}";`;
case 'forward':
return `redirect "${escapeString(action.value || '')}";`;
case 'mark_read':
return 'addflag "\\\\Seen";';
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
return `reject "${escapeString(action.value || '')}";`;
case 'keep':
return 'keep;';
case 'stop':
return 'stop;';
}
});
}
function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] {
const extensions = new Set<string>();
const enabledRules = rules.filter(r => r.enabled);
if (vacation?.isEnabled) {
extensions.add('vacation');
}
for (const rule of enabledRules) {
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
}
for (const action of rule.actions) {
switch (action.type) {
case 'move':
extensions.add('fileinto');
break;
case 'copy':
extensions.add('fileinto');
extensions.add('copy');
break;
case 'mark_read':
case 'star':
case 'add_label':
extensions.add('imap4flags');
break;
case 'reject':
extensions.add('reject');
break;
}
}
}
return [...extensions];
}
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
matchType: r.matchType,
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
};
}
export interface GenerateOptions {
/**
* Require extensions used by external (non-Bulwark) rules that we must
* preserve in the top-level `require` directive. Duplicates with Bulwark's
* own requires are deduplicated.
*/
externalRequires?: string[];
}
export function generateScript(
rules: FilterRule[],
vacation?: VacationSieveConfig,
options: GenerateOptions = {},
): string {
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
const bulwarkRules: FilterRule[] = [];
const externalRules: FilterRule[] = [];
for (const r of rules) {
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
else bulwarkRules.push(r);
}
const metadata: FilterMetadata = {
version: 1,
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
};
if (vacation?.isEnabled) {
metadata.vacation = vacation;
}
const metadataJson = JSON.stringify(metadata);
const lines: string[] = [];
lines.push('/* @metadata:begin');
lines.push(metadataJson);
lines.push('@metadata:end */');
lines.push('');
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
if (allRequires.length > 0) {
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
}
if (vacation?.isEnabled) {
lines.push('');
lines.push('# Vacation auto-reply');
const vacationParts: string[] = [];
if (vacation.subject) {
vacationParts.push(`:subject "${escapeString(vacation.subject)}"`);
}
vacationParts.push(`"${escapeString(vacation.textBody || '')}"`);
lines.push(`vacation ${vacationParts.join(' ')};`);
}
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledBulwarkRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
// Empty conditions/actions can't form a valid `if`; the rule modal
// prevents this, but skip defensively rather than emit broken Sieve.
continue;
}
lines.push('');
lines.push(`# Rule: ${rule.name}`);
const conditions = rule.conditions.map(generateCondition);
let conditionStr: string;
if (conditions.length === 0) {
conditionStr = 'true';
} else if (conditions.length === 1) {
conditionStr = conditions[0];
} else {
const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof';
conditionStr = `${wrapper}(${conditions.join(', ')})`;
}
const actionLines = generateActions(rule.actions);
if (rule.stopProcessing) {
const lastAction = rule.actions[rule.actions.length - 1];
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
actionLines.push('stop;');
}
}
lines.push(`if ${conditionStr} {`);
for (const actionLine of actionLines) {
lines.push(` ${actionLine}`);
}
lines.push('}');
}
// Append preserved external rules verbatim. Each rawBlock already carries its
// own leading comments and trailing whitespace from the source script.
if (externalRules.length > 0) {
lines.push('');
lines.push('# --- External rules (managed outside Bulwark) ---');
for (const ext of externalRules) {
if (!ext.rawBlock) continue;
lines.push(ext.rawBlock.replace(/\s+$/, ''));
}
}
lines.push('');
return lines.join('\n');
}
+759
View File
@@ -0,0 +1,759 @@
import type {
FilterAction,
FilterCondition,
FilterComparator,
FilterConditionField,
FilterMetadata,
FilterRule,
VacationSieveConfig,
} from './types';
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
// are best-effort parsed into read-only "external" rules or preserved opaquely.
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
vacation?: VacationSieveConfig;
externalRequires: string[];
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const BULWARK_EXTERNAL_HEADER_RE =
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
cc: 'cc',
subject: 'subject',
};
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
}
function isValidAction(a: unknown): boolean {
if (!a || typeof a !== 'object') return false;
const act = a as Record<string, unknown>;
return typeof act.type === 'string';
}
function isValidRule(rule: unknown): rule is FilterRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>;
if (
typeof r.id !== 'string' ||
typeof r.name !== 'string' ||
typeof r.enabled !== 'boolean' ||
(r.matchType !== 'all' && r.matchType !== 'any') ||
!Array.isArray(r.conditions) ||
!Array.isArray(r.actions) ||
typeof r.stopProcessing !== 'boolean'
) return false;
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
}
/**
* Detect Stalwart-generated vacation-only scripts (no metadata).
*/
function detectVacationOnlyScript(content: string): ParseResult | null {
if (!/\bvacation\b/.test(content)) return null;
const stripped = content
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
if (!/\bvacation\b/.test(structural)) return null;
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
let textBody = '';
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
if (mimeBodyMatch) {
textBody = mimeBodyMatch[1].trim();
} else {
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
const last = allQuoted[allQuoted.length - 1];
if (last) textBody = unescapeSieveString(last[1]);
}
return {
rules: [],
isOpaque: false,
vacation: { isEnabled: true, subject, textBody },
externalRequires: [],
};
}
function unescapeSieveString(s: string): string {
return s.replace(/\\(.)/g, '$1');
}
function skipStringLit(s: string, i: number): number {
i++;
while (i < s.length) {
if (s[i] === '\\') { i += 2; continue; }
if (s[i] === '"') return i + 1;
i++;
}
return i;
}
function skipHashComment(s: string, i: number): number {
while (i < s.length && s[i] !== '\n') i++;
return i;
}
function skipBlockComment(s: string, i: number): number {
const end = s.indexOf('*/', i + 2);
return end === -1 ? s.length : end + 2;
}
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === ';') return i + 1;
i++;
}
return i;
}
function skipBalanced(s: string, i: number, open: string, close: string): number {
let depth = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === open) { depth++; i++; continue; }
if (c === close) {
depth--;
i++;
if (depth === 0) return i;
continue;
}
i++;
}
return i;
}
function skipIfStatement(s: string, i: number): number {
// positioned after 'if' keyword; skip through condition expression and body braces
while (i < s.length && s[i] !== '{') {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
i++;
}
if (i >= s.length) return i;
return skipBalanced(s, i, '{', '}');
}
interface TopBlock {
kind: 'require' | 'if' | 'vacation' | 'other';
raw: string; // from start-of-leading-text to end of statement
statement: string; // the statement itself (no leading comments/whitespace)
startIdx: number;
endIdx: number;
}
function scanTopLevel(content: string): TopBlock[] {
const blocks: TopBlock[] = [];
let i = 0;
let segmentStart = 0;
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
blocks.push({
kind,
raw: content.slice(segmentStart, stmtEnd),
statement: content.slice(stmtStart, stmtEnd),
startIdx: segmentStart,
endIdx: stmtEnd,
});
segmentStart = stmtEnd;
};
while (i < content.length) {
// Skip whitespace
while (i < content.length && /\s/.test(content[i])) i++;
if (i >= content.length) break;
const c = content[i];
// Comments (stay attached to next block as leading text)
if (c === '#') { i = skipHashComment(content, i); continue; }
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
// Identifier
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
if (!m) { i++; continue; }
const ident = m[0];
const stmtStart = i;
i += ident.length;
if (ident === 'require') {
i = skipStatement(content, i);
consume('require', stmtStart, i);
} else if (ident === 'if') {
i = skipIfStatement(content, i);
consume('if', stmtStart, i);
} else if (ident === 'vacation') {
i = skipStatement(content, i);
consume('vacation', stmtStart, i);
} else {
i = skipStatement(content, i);
consume('other', stmtStart, i);
}
}
return blocks;
}
function extractRequireTokens(stmt: string): string[] {
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
return mSingle ? [mSingle[1]] : [];
}
/**
* Extract the last contiguous block of comments immediately preceding a
* statement - comments separated from the statement by a blank line are not
* considered its leading commentary (they likely belong to the previous
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
*/
function lastCommentChunk(leading: string): string {
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
function detectOriginLabel(leading: string): string {
const chunk = lastCommentChunk(leading);
const lower = chunk.toLowerCase();
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
if (/nextcloud/.test(lower)) return 'Nextcloud';
if (/horde|ingo/.test(lower)) return 'Horde';
if (/kolab/.test(lower)) return 'Kolab';
if (/dovecot/.test(lower)) return 'Dovecot';
if (/thunderbird/.test(lower)) return 'Thunderbird';
return 'External';
}
function extractName(leading: string, fallback: string): string {
// Roundcube: "# rule:[Name]"
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
if (rc) return rc[1].trim();
// "# Rule: Name"
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
if (rr) return rr[1].trim();
// Last non-empty trimmed comment line
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
return fallback;
}
function splitTopLevelComma(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
if (c === ',' && depth === 0) {
parts.push(s.slice(start, i));
start = i + 1;
}
i++;
}
parts.push(s.slice(start));
return parts.map(p => p.trim()).filter(Boolean);
}
function splitStatements(body: string): string[] {
const stmts: string[] = [];
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '"') { i = skipStringLit(body, i); continue; }
if (c === '#') { i = skipHashComment(body, i); continue; }
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
if (c === ';') {
stmts.push(body.slice(start, i));
start = i + 1;
}
i++;
}
const tail = body.slice(start).trim();
if (tail) stmts.push(tail);
return stmts.map(s => s.trim()).filter(Boolean);
}
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
const lc = name.toLowerCase();
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
return { field: 'header', headerName: name };
}
function parseAtom(raw: string): FilterCondition | null {
let s = raw.trim();
let negated = false;
if (/^not\b/.test(s)) {
negated = true;
s = s.replace(/^not\s*/, '').trim();
if (s.startsWith('(') && s.endsWith(')')) {
s = s.slice(1, -1).trim();
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
} else {
// :matches - distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
}
const cond: FilterCondition = { field, comparator, value };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
if (m) {
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
}
return null;
}
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
const s = raw.trim();
if (!s) return null;
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
let matchType: 'all' | 'any' = 'all';
let inner: string;
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
else inner = s;
const parts = splitTopLevelComma(inner);
const conditions: FilterCondition[] = [];
for (const part of parts) {
const atom = parseAtom(part);
if (!atom) return null;
conditions.push(atom);
}
return { matchType, conditions };
}
function parseAction(raw: string): FilterAction | null {
const s = raw.trim();
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const flag = unescapeSieveString(m[1]);
if (flag === '\\Seen') return { type: 'mark_read' };
if (flag === '\\Flagged') return { type: 'star' };
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
return null;
}
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
if (/^discard$/.test(s)) return { type: 'discard' };
if (/^keep$/.test(s)) return { type: 'keep' };
if (/^stop$/.test(s)) return { type: 'stop' };
return null;
}
function findBodyOpenBrace(s: string): number {
// Locate the first `{` that introduces the if-block body, skipping over
// string literals and comments. A naive indexOf('{') would otherwise pick
// up braces inside condition values (e.g. `:contains "{foo}"`).
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === '{') return i;
i++;
}
return -1;
}
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
const stmt = block.statement;
const afterIf = stmt.replace(/^if\s+/, '');
const braceIdx = findBodyOpenBrace(afterIf);
// scanTopLevel's skipIfStatement uses balanced-brace scanning, so the
// statement always terminates at the matching `}`.
const lastBraceIdx = afterIf.length - 1;
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
const condStr = afterIf.slice(0, braceIdx).trim();
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
const cond = parseCondition(condStr);
if (!cond || cond.conditions.length === 0) return null;
const actionStmts = splitStatements(bodyStr);
const actions: FilterAction[] = [];
for (const st of actionStmts) {
const a = parseAction(st);
if (!a) return null;
actions.push(a);
}
if (actions.length === 0) return null;
let stopProcessing = false;
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
const hasNonStop = actions.some(a => a.type !== 'stop');
if (hasNonStop) {
stopProcessing = true;
actions.pop();
}
}
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `Rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: cond.matchType,
conditions: cond.conditions,
actions,
stopProcessing,
origin: 'external',
originLabel,
rawBlock: block.raw,
};
}
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `External rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel,
rawBlock: block.raw,
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Nextcloud Mail wraps its managed filter region with a pair of
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
* emits two such regions - one enclosing its own `require [...]` line and
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
* JSON comments. Parsing the interior blocks individually loses the outer
* markers (causing later rules to fall back to "External") and mis-attaches
* them to neighboring blocks on round-trip.
*
* Treat each marker-pair as one opaque external rule so the whole region
* round-trips verbatim, merge any require tokens into the top-level list,
* and drop regions that carry nothing but a require (their extensions are
* already represented in the merged require line).
*/
function extractNextcloudRegions(content: string): {
cleaned: string;
rules: FilterRule[];
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
const positions: number[] = [];
let searchFrom = 0;
while (true) {
const idx = content.indexOf(marker, searchFrom);
if (idx === -1) break;
const atLineStart = idx === 0 || content[idx - 1] === '\n';
if (atLineStart) positions.push(idx);
searchFrom = idx + marker.length;
}
if (positions.length < 2) {
return { cleaned: content, rules: [], requires: [] };
}
const rules: FilterRule[] = [];
const requires: string[] = [];
const markerRe = new RegExp(escapeRegex(marker), 'g');
let cleaned = '';
let cursor = 0;
for (let i = 0; i + 1 < positions.length; i += 2) {
const start = positions[i];
const closeStart = positions[i + 1];
const lineEnd = content.indexOf('\n', closeStart + marker.length);
const end = lineEnd === -1 ? content.length : lineEnd + 1;
cleaned += content.slice(cursor, start);
const raw = content.slice(start, end);
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
if (!requires.includes(tok[1])) requires.push(tok[1]);
}
}
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
const stripped = raw
.replace(markerRe, '')
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
.replace(/require\s+"[^"]+"\s*;/g, '')
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
if (stripped.length > 0) {
rules.push({
id: `nextcloud-${rules.length}`,
name: 'Nextcloud Mail filters',
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel: 'Nextcloud',
rawBlock: raw,
});
}
cursor = end;
}
cleaned += content.slice(cursor);
return { cleaned, rules, requires };
}
function stripBulwarkExternalHeader(content: string): string {
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
}
function parseExternalRules(
content: string,
idPrefix: string,
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
const blocks = scanTopLevel(content);
const rules: FilterRule[] = [];
const externalRequires: string[] = [];
let index = 0;
let sawAnyStatement = false;
for (const block of blocks) {
if (block.kind === 'require') {
sawAnyStatement = true;
for (const tok of extractRequireTokens(block.statement)) {
if (!externalRequires.includes(tok)) externalRequires.push(tok);
}
continue;
}
if (block.kind === 'if') {
sawAnyStatement = true;
const rule = parseIfBlockToRule(block, idPrefix, index);
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
index++;
continue;
}
// vacation/other: treat as opaque preserved block
sawAnyStatement = true;
rules.push(makeOpaqueRule(block, idPrefix, index));
index++;
}
return { rules, externalRequires, hasContent: sawAnyStatement };
}
export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx !== -1) {
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
console.warn('[filters] Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
// it so it does not get re-attached to the first external rule's rawBlock
// and written out twice on the next save.
const afterMetadata = stripBulwarkExternalHeader(
content.slice(endIdx + METADATA_END.length),
);
const nextcloud = extractNextcloudRegions(afterMetadata);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
const externalRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
// This applies regardless of whether the block parsed as a structured rule or
// fell back to opaque - a Bulwark-emitted block may fail to round-trip cleanly
// (e.g. a value with literal braces) but the `# Rule: <name>` marker still
// identifies it as ours.
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
if (match) {
const name = match[1].trim();
if (bulwarkRules.some(b => b.name === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;
});
return {
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
};
}
// No metadata - check vacation-only first
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
// Extract Nextcloud-managed marker regions first so their interior is not
// parsed as a series of loose if-blocks (which would lose the outer markers
// and mis-label later blocks as generic "External").
const nextcloud = extractNextcloudRegions(content);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
const allRules = [...nextcloud.rules, ...external.rules];
const allRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
if (!external.hasContent && allRules.length === 0) {
// Entirely empty or whitespace/comments only - treat as empty, editable.
// Preserve any require tokens lifted out of Nextcloud marker regions so
// they can be re-emitted in the top-level require line.
return { rules: [], isOpaque: false, externalRequires: allRequires };
}
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || allRules.length > 0) {
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
}
return OPAQUE;
}
+72
View File
@@ -0,0 +1,72 @@
// Sieve filter data model. Ported from the webmail's lib/jmap/sieve-types.ts so
// the generated scripts round-trip byte-for-byte between the two clients (a
// rule authored on the web reads back as a structured rule on mobile and vice
// versa, because both embed the same `@metadata` JSON block).
export interface SieveScript {
id: string;
name: string;
blobId: string;
isActive: boolean;
}
export interface SieveCapabilities {
implementation: string;
maxSizeScript: number;
sieveExtensions: string[];
notificationMethods: string[];
externalLists: string[];
}
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
export type FilterComparator =
| 'contains' | 'not_contains'
| 'is' | 'not_is'
| 'starts_with' | 'ends_with'
| 'matches'
| 'greater_than' | 'less_than';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
| 'mark_read' | 'star' | 'add_label'
| 'discard' | 'reject' | 'keep' | 'stop';
export interface FilterCondition {
field: FilterConditionField;
comparator: FilterComparator;
value: string;
headerName?: string;
}
export interface FilterAction {
type: FilterActionType;
value?: string;
}
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
export interface FilterRule {
id: string;
name: string;
enabled: boolean;
matchType: 'all' | 'any';
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
}
export interface VacationSieveConfig {
isEnabled: boolean;
subject: string;
textBody: string;
}
export interface FilterMetadata {
version: 1;
rules: FilterRule[];
vacation?: VacationSieveConfig;
}
+95
View File
@@ -0,0 +1,95 @@
// TOTP enrolment helper. Mirrors the webmail's use of `otpauth` (Settings →
// Security → Two-Factor) but without pulling in a native crypto dependency:
// we only need to mint a fresh shared secret and the `otpauth://` enrolment
// URL. The 6-digit code the user types is verified server-side by Stalwart
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
// device — that keeps this file dependency-free.
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
const SECRET_BYTES = 20;
function randomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
const c = (globalThis as {
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
}).crypto;
// Best: a real CSPRNG (web / a polyfilled RN).
if (c?.getRandomValues) {
try {
c.getRandomValues(bytes);
return bytes;
} catch {
// fall through
}
}
// Good enough: derive bytes from crypto.randomUUID, which Hermes exposes
// even when getRandomValues isn't polyfilled (~122 bits of entropy each).
if (c?.randomUUID) {
try {
let hex = '';
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
} catch {
// fall through
}
}
// Last resort: Math.random. Same trade-off the rest of the app accepts in
// lib/uuid.ts and lib/oauth.ts; the secret is one-off and server-validated.
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
return bytes;
}
// RFC 4648 base32, no padding — the form authenticator apps expect in the
// `secret` query parameter.
function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = '';
for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
export interface TotpEnrolment {
// Base32 secret, shown to the user for manual entry.
secret: string;
// Same secret grouped in fours for easier manual typing.
secretFormatted: string;
// otpauth:// URL — feeds the "Add to authenticator app" deep link and the
// value sent to the server as otpUrl.
url: string;
}
export function generateTotpEnrolment(accountLabel: string): TotpEnrolment {
const secret = base32Encode(randomBytes(SECRET_BYTES));
const issuer = 'Stalwart';
const label = accountLabel || 'account';
const params = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: '6',
period: '30',
});
// Label is conventionally "Issuer:account"; both label path and issuer param
// are encoded so authenticators that read either render a sensible name.
const url = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?${params.toString()}`;
const secretFormatted = secret.replace(/(.{4})/g, '$1 ').trim();
return { secret, secretFormatted, url };
}
+2 -1
View File
@@ -2,7 +2,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string };
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
Compose:
| {
@@ -13,6 +13,7 @@ export type RootStackParamList = {
cc?: EmailAddress[];
subject: string;
body?: string;
receivedAt?: string;
inReplyTo?: string;
references?: string;
};
+71 -7
View File
@@ -52,9 +52,13 @@ import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import {
applySharedCalendarColors,
buildEventDayIndex,
eventsOnDayFromIndex,
pickUnusedCalendarColor,
sharedCalendarColorKey,
type EventDayIndex,
type TimeFormat,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
@@ -117,6 +121,9 @@ export default function CalendarScreen() {
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const contacts = useContactsStore((s) => s.contacts);
const [currentDate, setCurrentDate] = React.useState(new Date());
@@ -154,6 +161,7 @@ export default function CalendarScreen() {
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const storeCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
@@ -169,9 +177,41 @@ export default function CalendarScreen() {
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
// Per-viewer recolor (#345): shared calendars get the viewer's local color
// override applied before anything renders. Personal calendars pass through.
const displayCalendars = React.useMemo(
() => applySharedCalendarColors(storeCalendars, sharedCalendarColors),
[storeCalendars, sharedCalendarColors],
);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
React.useEffect(() => {
const missing = storeCalendars.filter(
(cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)],
);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const cal of storeCalendars) {
if (!cal.isShared && cal.color) used.add(cal.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
}
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = React.useMemo(
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
[storeCalendars, showBirthdayCalendar],
() => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
[displayCalendars, showBirthdayCalendar],
);
const allEvents = React.useMemo(
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
@@ -252,25 +292,34 @@ export default function CalendarScreen() {
[],
);
// A recurring series member is a master (has recurrenceRules) or an expanded
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
// we must NOT key off originalId here, since shared/group events now carry it
// for id-namespacing even when they aren't recurring.
const isRecurringSeriesMember = React.useCallback(
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect, isReadOnlyEvent]);
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent, isReadOnlyEvent]);
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -420,6 +469,7 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
/>
)}
@@ -436,6 +486,7 @@ export default function CalendarScreen() {
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
refreshing={refreshing}
onRefresh={onRefresh}
@@ -447,6 +498,7 @@ export default function CalendarScreen() {
<EventDetailSheet
event={detailEvent}
calendars={calendars}
timeFormat={calendarTimeFormat}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -491,6 +543,13 @@ export default function CalendarScreen() {
onClose={() => setSidebarVisible(false)}
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }}
onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a fresh
// unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
/>
<TasksSheet
@@ -505,7 +564,9 @@ export default function CalendarScreen() {
<ICalImportSheet
visible={importVisible}
calendars={storeCalendars}
// Import batch-creates against the primary account, so only offer the
// user's own calendars as targets.
calendars={displayCalendars.filter((cal) => !cal.isShared)}
onClose={() => setImportVisible(false)}
onImport={importEvents}
/>
@@ -522,6 +583,7 @@ function DayEventList({
date,
eventsByDay,
calendars,
timeFormat,
onSelectEvent,
refreshing,
onRefresh,
@@ -529,6 +591,7 @@ function DayEventList({
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
@@ -565,6 +628,7 @@ function DayEventList({
key={event.id}
event={event}
calendars={calendars}
timeFormat={timeFormat}
onPress={onSelectEvent}
/>
))}
+109 -32
View File
@@ -17,7 +17,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import { File as FsFile } from 'expo-file-system';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { Button, IdentitySheet } from '../components';
import RichTextEditor, {
type RichTextEditorHandle,
type RichTextSelectionState,
@@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useAccountStore } from '../stores/account-store';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -69,7 +70,16 @@ function parseRecipients(input: string): Recipient[] {
.split(/[,;\n]/)
.map((s) => s.trim())
.filter(Boolean)
.map((email) => ({ name: '', email }));
.map((s) => {
const match = s.match(/^(.*?)\s*<([^\s@]+@[^\s@]+\.[^\s@]+)>$/);
if (match) {
return {
name: match[1].replace(/^["']|["']$/g, '').trim(),
email: match[2].trim(),
};
}
return { name: '', email: s };
});
}
function formatBytes(n: number): string {
@@ -113,10 +123,11 @@ function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove
}
function SuggestionList({
suggestions, onPick,
suggestions, onPick, onPressIn,
}: {
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
onPick: (s: { name: string; email: string }) => void;
onPressIn?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -127,6 +138,7 @@ function SuggestionList({
return (
<Pressable
key={`${s.contact.id}-${s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
@@ -212,6 +224,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const insets = useSafeAreaInsets();
// Track the visible keyboard obstruction so the format bar stays above it.
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
@@ -258,6 +272,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false);
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
// can only show one field at a time, so we walk date → time.
@@ -317,10 +332,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
cc: replyTo.cc,
subject: replyTo.subject,
body: replyTo.body,
receivedAt: replyTo.receivedAt,
}
: null,
{ timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown') },
),
[mode, replyTo],
[mode, replyTo, timeFormat, locale, t],
);
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
@@ -339,6 +356,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
const editorRef = React.useRef<RichTextEditorHandle>(null);
const isPickingSuggestion = React.useRef(false);
// Track inline-image placeholders that haven't yet been rewritten to cid:
// until send time. Maps cid → blobId/type/name/size.
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
@@ -371,6 +389,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}, [suggestionQuery, individuals, alreadySelected]);
const pickSuggestion = (s: { name: string; email: string }) => {
isPickingSuggestion.current = true;
const recipient: Recipient = { name: s.name, email: s.email };
if (activeField === 'cc') {
setCcRecipients((prev) => [...prev, recipient]);
@@ -401,34 +420,35 @@ export default function ComposeScreen({ route, navigation }: Props) {
// original message (matches webmail's reply-identity behaviour).
React.useEffect(() => {
if (selectedIdentityId || identities.length === 0) return;
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
const defaultIdentity = identities.find(
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
) ?? identities[0];
if (autoSelectReplyIdentity && replyTo) {
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
const candidateAddrs = new Set(
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
);
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
setSelectedIdentityId((matched ?? identities[0]).id);
setSelectedIdentityId((matched ?? defaultIdentity).id);
} else {
setSelectedIdentityId(identities[0].id);
setSelectedIdentityId(defaultIdentity.id);
}
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
const primaryIdentity =
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
const primaryIdentity = React.useMemo(() => {
if (identities.length === 0) return null;
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
const defaultIdentity = identities.find(
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
) ?? identities[0];
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
}, [identities, selectedIdentityId]);
const openIdentityPicker = () => {
if (identities.length <= 1) return;
Alert.alert(
t('email_composer.from', 'From'),
undefined,
[
...identities.map((i) => ({
text: i.name ? `${i.name} <${i.email}>` : i.email,
onPress: () => setSelectedIdentityId(i.id),
})),
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
],
);
setIdentitySheetOpen(true);
};
const commitTyped = () => {
@@ -444,11 +464,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
const extraTo = parseRecipients(toInput);
const extraCc = parseRecipients(ccInput);
if (extraTo.length) {
setToRecipients((prev) => [...prev, ...extraTo]);
setToRecipients((prev) => {
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
const unique: Recipient[] = [];
for (const r of extraTo) {
const emailLower = r.email.toLowerCase();
if (!existing.has(emailLower)) {
existing.add(emailLower);
unique.push(r);
}
}
return [...prev, ...unique];
});
setToInput('');
}
if (extraCc.length) {
setCcRecipients((prev) => [...prev, ...extraCc]);
setCcRecipients((prev) => {
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
const unique: Recipient[] = [];
for (const r of extraCc) {
const emailLower = r.email.toLowerCase();
if (!existing.has(emailLower)) {
existing.add(emailLower);
unique.push(r);
}
}
return [...prev, ...unique];
});
setCcInput('');
}
};
@@ -972,7 +1014,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
style={styles.flex}
keyboardShouldPersistTaps="handled"
keyboardShouldPersistTaps="always"
contentContainerStyle={styles.scrollContent}
>
<View style={styles.fieldRow}>
@@ -989,7 +1031,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Pressable>
</View>
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 5 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
<View style={styles.recipientField}>
{toRecipients.map((r, i) => (
@@ -1007,8 +1049,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChangeText={setToInput}
onFocus={() => setActiveField('to')}
onBlur={() => {
addTyped();
setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200);
setTimeout(() => {
if (!isPickingSuggestion.current) {
addTyped();
}
setActiveField((f) => (f === 'to' ? null : f));
isPickingSuggestion.current = false;
}, 200);
}}
onSubmitEditing={addTyped}
keyboardType="email-address"
@@ -1017,7 +1064,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
</View>
{activeField === 'to' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
@@ -1027,7 +1080,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
{ccVisible && (
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 4 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
<View style={styles.recipientField}>
{ccRecipients.map((r, i) => (
@@ -1045,8 +1098,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChangeText={setCcInput}
onFocus={() => setActiveField('cc')}
onBlur={() => {
addTyped();
setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200);
setTimeout(() => {
if (!isPickingSuggestion.current) {
addTyped();
}
setActiveField((f) => (f === 'cc' ? null : f));
isPickingSuggestion.current = false;
}, 200);
}}
onSubmitEditing={addTyped}
keyboardType="email-address"
@@ -1055,7 +1113,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
</View>
{activeField === 'cc' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
</View>
)}
@@ -1289,6 +1353,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={onCustomPickerChange}
/>
)}
<IdentitySheet
visible={identitySheetOpen}
onClose={() => setIdentitySheetOpen(false)}
identities={identities}
selectedIdentityId={selectedIdentityId}
onPick={(identity) => setSelectedIdentityId(identity.id)}
/>
</SafeAreaView>
);
}
@@ -1328,6 +1400,7 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
gap: spacing.md,
position: 'relative',
},
fieldLabel: {
...typography.body,
@@ -1370,12 +1443,16 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 6,
},
suggestionBox: {
marginTop: spacing.xs,
marginLeft: 60,
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
suggestionRow: {
+91 -25
View File
@@ -1,11 +1,12 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as DocumentPicker from 'expo-document-picker';
import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag,
Archive, FolderInput, Tag, Import,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -21,8 +22,10 @@ import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
@@ -43,20 +46,6 @@ function isPinned(email: Email): boolean {
return !!email.keywords?.$important;
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
const diffMin = Math.floor(diffMs / (1000 * 60));
const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay === 1) return 'Yesterday';
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const EmailRow = React.memo(function EmailRow({
item,
@@ -79,6 +68,10 @@ const EmailRow = React.memo(function EmailRow({
const styles = React.useMemo(() => makeStyles(c), [c]);
const dyn = useTypography();
const density = useDensity();
// Read the date-rendering prefs here so each row re-renders when they change.
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const locale = useLocaleStore((s) => s.locale);
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -133,7 +126,7 @@ const EmailRow = React.memo(function EmailRow({
<Text style={styles.threadBadgeText}>{threadCount}</Text>
</View>
)}
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
<Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
</View>
</View>
@@ -171,6 +164,7 @@ interface EmailListScreenProps {
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { t } = useLocaleStore();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
const emails = useEmailStore((s) => s.emails);
@@ -184,6 +178,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const selectMailbox = useEmailStore((s) => s.selectMailbox);
const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails);
const refreshEmails = useEmailStore((s) => s.refreshEmails);
const importEmails = useEmailStore((s) => s.importEmails);
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
const setFilters = useEmailStore((s) => s.setFilters);
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
@@ -238,18 +233,62 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [emails, disableThreading]);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
() => findArchiveMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
const trashMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
() => findTrashMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
const junkMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
() => findJunkMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
const [importing, setImporting] = React.useState(false);
const handleImport = React.useCallback(async () => {
const targetMailboxId =
currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null;
if (!targetMailboxId || importing) return;
let result: DocumentPicker.DocumentPickerResult;
try {
result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
type: [
'message/rfc822',
'application/zip',
'application/x-zip-compressed',
'application/octet-stream',
],
});
} catch {
return; // picker dismissed / unavailable
}
if (result.canceled) return;
if (result.assets.length === 0) return;
setImporting(true);
try {
const files = result.assets.map((a) => ({
uri: a.uri,
name: a.name,
mimeType: a.mimeType,
}));
const { imported, failed } = await importEmails(files, targetMailboxId);
Alert.alert(
'Import',
failed > 0
? `${imported} imported, ${failed} failed.`
: imported === 0
? 'No messages were imported.'
: `${imported} message${imported === 1 ? '' : 's'} imported.`,
);
} finally {
setImporting(false);
}
}, [currentMailboxId, mailboxes, importEmails, importing]);
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
// Batch (multi-select) sheets.
@@ -298,16 +337,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void archiveEmailAction(id);
} else if (!archiveMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'),
);
}
break;
case 'delete':
if (trashMailboxId) {
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
} else {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
);
}
break;
case 'spam':
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
} else if (!junkMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'),
);
}
break;
case 'read':
@@ -327,7 +381,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
markRead, markUnread, toggleStar, togglePin,
markRead, markUnread, toggleStar, togglePin, t,
]);
const renderEmailRow = React.useCallback(
@@ -395,7 +449,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
};
const handleBulkDelete = async () => {
const trash = mailboxes.find((m) => m.role === 'trash');
const trash = findTrashMailbox(mailboxes);
if (!trash || !currentMailboxId) {
clearSelection();
return;
@@ -555,8 +609,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
style={styles.headerButton}
disabled={importing}
hitSlop={6}
>
{importing ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Import size={20} color={c.textMuted} />
)}
</Pressable>
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
@@ -1025,7 +1091,7 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
headerLogo: {
width: 28,
height: 28,
+1 -1
View File
@@ -34,7 +34,7 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
const onShare = async () => {
try {
await shareEmailEml(blobId, subject);
await shareEmailEml(blobId, undefined, subject);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
+554 -214
View File
@@ -1,8 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert,
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
} from 'react-native';
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
@@ -18,11 +19,18 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
type QuickAction,
} from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { findTrashMailbox } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -30,8 +38,13 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
return d.toLocaleDateString(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
});
}
function formatHeaderTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: '2-digit', minute: '2-digit',
});
}
@@ -54,7 +67,12 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { emailId, subject: subjectParam } = route.params;
const { t } = useLocaleStore();
const { jmapAccountId } = route.params;
// The displayed email is tracked in local state (not a route param) so that
// swiping / Prev-Next can switch messages in place without remounting the
// screen — which is what produced the loading flash on every change.
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
@@ -66,40 +84,115 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject ?? '',
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
// In-memory cache of fetched message details keyed by id. This is the single
// source of truth for every rendered pane: the active message *and* its
// prefetched neighbours all read their detail from here, so a swipe lands on
// ready content with no spinner and no late content-swap. `cacheVersion` is
// bumped whenever an entry changes so the panes re-render.
const detailCache = React.useRef(new Map<string, Email>()).current;
const [cacheVersion, setCacheVersion] = React.useState(0);
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
const email = React.useMemo(
() => detailCache.get(activeEmailId) ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmailId, cacheVersion, detailCache],
);
// --- Pager -------------------------------------------------------------
// The pager is a horizontal, page-snapping FlatList over the mailbox's
// `emails`. Native scroll provides the swipe + snap (and plays nicely with
// each pane's vertical body scroll); Prev/Next scroll programmatically. When
// scrolling settles, the centred page's id becomes `activeEmailId`, which is
// what the toolbar and action handlers operate on.
const listRef = React.useRef<FlatList<Email>>(null);
// The page to open on mount — the entry point arrives as a route param, so
// capture its index once.
const initialIndexRef = React.useRef(
Math.max(0, emails.findIndex((e) => e.id === route.params.emailId)),
);
// Coalesced detail fetch: stores the result in the shared cache and bumps the
// version so every mounted pane (and the toolbar) re-reads it. Concurrent
// calls for the same id share a single in-flight request.
const inFlight = React.useRef(new Map<string, Promise<Email | null>>()).current;
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
const pending = inFlight.get(id);
if (pending) return pending;
const p = getEmailDetail(id, jmapAccountId)
.then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; })
.catch(() => detailCache.get(id) ?? null)
.finally(() => { inFlight.delete(id); });
inFlight.set(id, p);
return p;
}, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]);
// Slide to a page by index and reflect it in the toolbar immediately; a swipe
// that settles on the same page confirms the same id via onMomentumEnd.
const goToIndex = React.useCallback((index: number) => {
if (index < 0 || index >= emails.length) return;
listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: true });
const target = emails[index];
if (target) setActiveEmailId(target.id);
}, [emails, windowWidth]);
// Adopt the centred page as the active message once a swipe settles.
const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth);
const target = emails[index];
if (target && target.id !== activeEmailId) setActiveEmailId(target.id);
}, [emails, windowWidth, activeEmailId]);
// Page offsets are in window-width units, so a rotation / resize invalidates
// the scroll position — re-centre the active page after the width changes.
React.useLayoutEffect(() => {
const index = emails.findIndex((e) => e.id === activeEmailId);
if (index >= 0) listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowWidth]);
// While the body is pinch-zoomed (or a pinch is in flight) horizontal
// gestures pan the zoomed content, so the pager must not treat them as page
// swipes. Prev/Next stay usable: programmatic scrollToOffset ignores
// scrollEnabled. Reset when the active message changes — the zoomed pane's
// WebView can unmount without reporting back.
const [pagerLocked, setPagerLocked] = React.useState(false);
React.useEffect(() => { setPagerLocked(false); }, [activeEmailId]);
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const bottomActions = React.useMemo(
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
[bottomQuickActionsRaw],
);
// Any reply-family action the user pulled out of the bottom bar is surfaced
// in the top toolbar so it stays reachable.
const relocatedActions = React.useMemo(
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
[bottomActions],
);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
async (target: Email, blobId: string, name: string | undefined, type: string | undefined) => {
if (downloadingBlobId) return;
setDownloadingBlobId(blobId);
try {
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type);
await downloadAttachment(blobId, name, type, target);
} else {
await shareAttachment(blobId, name, type);
await shareAttachment(blobId, name, type, target);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
@@ -109,64 +202,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
},
[downloadingBlobId, mailAttachmentAction],
);
const renderAttachments = () => {
const all = email?.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -175,35 +210,34 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
// Refresh the active message — a cached copy (the pane may have fetched it,
// or it was visited before) renders immediately while this lands. Then mark
// it read per the user's delay. The pane itself fetches its own detail, so
// ensureDetail here usually rides the same in-flight request.
setError(null);
void (async () => {
try {
const fetched = await getEmailDetail(emailId);
if (cancelled) return;
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId);
}, markAsReadDelay);
} else {
void markRead(emailId);
}
const fetched = await ensureDetail(activeEmailId);
if (cancelled) return;
if (!fetched) {
// Only surface an error if there's nothing cached to show.
if (!detailCache.has(activeEmailId)) setError('Failed to load email');
return;
}
if (!fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(activeEmailId, jmapAccountId);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Failed to load email');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -222,8 +256,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
const updateLocalKeywords = (id: string, next: Record<string, boolean>) => {
const prev = detailCache.get(id);
if (!prev) return;
detailCache.set(id, { ...prev, keywords: next });
bumpCache();
};
const onToggleKeyword = (token: string) => {
@@ -231,36 +270,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const next = { ...email.keywords };
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
};
const onToggleStar = () => {
if (!email) return;
const next = { ...email.keywords };
if (starred) delete next.$flagged;
// Toggle the star on a specific message — used both by the toolbar (current
// message) and by each pane's own subject star.
const toggleStarFor = React.useCallback((target: Email) => {
const next = { ...target.keywords };
if (next.$flagged) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const prev = detailCache.get(target.id);
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
void setEmailKeywords(target.id, next);
}, [detailCache, bumpCache]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
updateLocalKeywords({ ...email.keywords, $seen: true });
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
}
};
const onDelete = () => {
if (!email || !currentMailboxId) return;
const trash = mailboxes.find((m) => m.role === 'trash');
if (!trash) return;
const trash = findTrashMailbox(mailboxes);
if (!trash) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
);
return;
}
void deleteEmail(email.id, trash.id, currentMailboxId);
navigation.goBack();
};
@@ -306,13 +355,84 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cc: email.cc,
subject: email.subject ?? '',
body: plainTextBody(email),
receivedAt: email.receivedAt,
inReplyTo: email.id,
},
});
};
const subject = email?.subject ?? subjectParam ?? '(no subject)';
const from = email?.from?.[0];
// Registry of every action that can live in the bottom quick-action bar (or
// be relocated to the top toolbar). `icon` is a factory so the same action
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
const quickActionRegistry: Record<
QuickAction,
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
> = {
reply: {
label: 'Reply',
icon: (s, col) => <Reply size={s} color={col} />,
onPress: () => navigateCompose('reply'),
available: true,
},
replyAll: {
label: 'Reply All',
icon: (s, col) => <ReplyAll size={s} color={col} />,
onPress: () => navigateCompose('replyAll'),
available: true,
},
forward: {
label: 'Forward',
icon: (s, col) => <Forward size={s} color={col} />,
onPress: () => navigateCompose('forward'),
available: true,
},
delete: {
label: 'Delete',
icon: (s, col) => <Trash2 size={s} color={col} />,
onPress: onDelete,
available: true,
},
archive: {
label: 'Archive',
icon: (s, col) => <Archive size={s} color={col} />,
onPress: onArchive,
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
},
markUnread: {
label: unread ? 'Read' : 'Unread',
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
onPress: onToggleUnread,
available: true,
},
star: {
label: starred ? 'Unstar' : 'Star',
icon: (s, col) => (
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
),
onPress: onToggleStar,
available: true,
},
move: {
label: 'Move',
icon: (s, col) => <FolderInput size={s} color={col} />,
onPress: () => setMoveMenuOpen(true),
available: mailboxes.length > 0,
},
spam: {
label: isInJunk ? 'Not spam' : 'Spam',
icon: (s, col) =>
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
onPress: onToggleSpam,
available: !!junkMailbox || isInJunk,
},
tag: {
label: 'Tag',
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
},
};
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
@@ -328,6 +448,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
{relocatedActions.map((id) => {
const def = quickActionRegistry[id];
return (
<ToolbarButton
key={id}
icon={def.icon(18, c.textSecondary)}
label={def.label}
onPress={def.onPress}
/>
);
})}
<ToolbarButton
icon={<Trash2 size={18} color={c.textSecondary} />}
label="Delete"
@@ -372,110 +503,82 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</View>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
{error && !email ? (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : email ? (
) : (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<View style={styles.subjectMain}>
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
<Text style={styles.subjectText}>{subject}</Text>
</View>
<View style={styles.subjectMeta}>
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
{email.size > 0 && (
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
)}
</View>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
{/* Pager: a horizontal page-snapping list over the mailbox. Each
page is the full-width pane for one message; native scroll slides
between them and onMomentumEnd reports the settled page. */}
<FlatList
ref={listRef}
style={styles.pagerViewport}
data={emails}
extraData={cacheVersion}
keyExtractor={(item) => item.id}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndexRef.current}
getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })}
windowSize={3}
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
scrollEnabled={!pagerLocked}
onMomentumScrollEnd={onMomentumEnd}
renderItem={({ item, index }) => (
<View style={{ width: windowWidth }}>
<EmailPane
id={item.id}
email={detailCache.get(item.id) ?? null}
ensureDetail={ensureDetail}
c={c}
styles={styles}
bottomBarHeight={bottomBarHeight}
attachmentPosition={attachmentPosition}
hideInlineImageAttachments={hideInlineImageAttachments}
downloadingBlobId={downloadingBlobId}
onToggleStar={toggleStarFor}
onPressAttachment={onPressAttachment}
onSwipe={(dir) => goToIndex(dir === 'next' ? index + 1 : index - 1)}
onZoomChange={(z) => setPagerLocked(z.pinching || z.zoomed)}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
</View>
</ScrollView>
)}
/>
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label="Prev"
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={c.textSecondary} />}
label="Reply"
onPress={() => navigateCompose('reply')}
/>
<BottomBarButton
icon={<ReplyAll size={20} color={c.textSecondary} />}
label="Reply All"
onPress={() => navigateCompose('replyAll')}
/>
<BottomBarButton
icon={<Forward size={20} color={c.textSecondary} />}
label="Forward"
onPress={() => navigateCompose('forward')}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
</>
) : null}
)}
<MoreMenuSheet
visible={moreMenuOpen}
@@ -508,7 +611,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email.subject);
await shareEmailEml(email.blobId, email);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
@@ -534,6 +637,229 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
}
interface EmailPaneProps {
id: string;
email: Email | null;
ensureDetail: (id: string) => Promise<Email | null>;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
attachmentPosition: 'beside-sender' | 'below-header';
hideInlineImageAttachments: boolean;
downloadingBlobId: string | null;
onToggleStar: (email: Email) => void;
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
onSwipe: (direction: 'prev' | 'next') => void;
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
// One swipeable page: the scrollable subject / sender / attachments / body for a
// single message. The pager keeps three of these mounted (prev, current, next)
// so a swipe slides ready content into view. Each pane owns its own vertical
// scroll position and "show all attachments" toggle, and renders directly from
// the email passed to it — neighbours show real content, not a placeholder.
function EmailPane({
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
onZoomChange,
}: EmailPaneProps) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
// finger zoom can't fling the page; while merely zoomed, vertical scrolling
// stays on — it is how the user pans the (taller) zoomed content vertically.
const [pinching, setPinching] = React.useState(false);
// Each pane owns loading its own message: when the list mounts this page and
// its detail isn't cached yet, fetch it (coalesced upstream). The shared
// cache then re-renders the pane with real content.
React.useEffect(() => {
if (!email) void ensureDetail(id);
}, [id, email, ensureDetail]);
// Detail not fetched yet. Show a skeleton of the pane layout instead of a
// spinner so the transition into real content doesn't jump.
if (!email) {
return <EmailPaneSkeleton styles={styles} />;
}
const from = email.from?.[0];
const starred = !!email.keywords?.$flagged;
const subject = email.subject || '(no subject)';
const renderAttachments = () => {
const all = email.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(email, att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
scrollEnabled={!pinching}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<Text style={styles.subjectText}>{subject}</Text>
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
<View style={styles.senderMeta}>
<Text style={styles.senderDate}>{formatHeaderDate(email.receivedAt)}</Text>
<Text style={styles.senderTime}>
{formatHeaderTime(email.receivedAt)}
{email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView
email={email}
senderEmail={from?.email}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
/>
</View>
</ScrollView>
);
}
// Placeholder mimicking the pane layout (subject, sender, body lines) shown
// while a message's detail is loading. One pulsing opacity over static bones
// keeps it cheap enough to also sit in the off-screen neighbour panes.
function EmailPaneSkeleton({ styles }: { styles: ReturnType<typeof makeStyles> }) {
const pulse = React.useRef(new Animated.Value(0.55)).current;
React.useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0.55, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [pulse]);
const bodyLineWidths = ['92%', '100%', '85%', '96%', '60%', '88%', '74%', '40%'] as const;
return (
<Animated.View style={[styles.scroll, { opacity: pulse }]}>
<View style={styles.subjectBlock}>
<View style={[styles.skeletonBone, { height: 20, width: '88%' }]} />
</View>
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<View style={styles.skeletonAvatar} />
<View style={styles.senderInfo}>
<View style={[styles.skeletonBone, { height: 14, width: '55%' }]} />
<View style={[styles.skeletonBone, { height: 11, width: '70%', marginTop: 6 }]} />
<View style={[styles.skeletonBone, { height: 11, width: '45%', marginTop: 6 }]} />
</View>
<View style={styles.senderMeta}>
<View style={[styles.skeletonBone, { height: 11, width: 86 }]} />
<View style={[styles.skeletonBone, { height: 11, width: 64, marginTop: 6 }]} />
</View>
</View>
</View>
<View style={styles.skeletonBody}>
{bodyLineWidths.map((w, i) => (
<View key={i} style={[styles.skeletonBone, { height: 12, width: w }]} />
))}
</View>
</Animated.View>
);
}
interface MoreMenuSheetProps {
visible: boolean;
onClose: () => void;
@@ -855,6 +1181,8 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
// Pager: the horizontal page-snapping list fills the area between the bars.
pagerViewport: { flex: 1, backgroundColor: c.background },
scroll: { flex: 1, backgroundColor: c.background },
// Subject block
@@ -870,12 +1198,6 @@ function makeStyles(c: ThemePalette) {
alignItems: 'flex-start',
gap: spacing.sm,
},
subjectMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
subjectStar: {
paddingTop: 4,
},
@@ -887,18 +1209,6 @@ function makeStyles(c: ThemePalette) {
color: c.text,
letterSpacing: -0.2,
},
subjectMeta: {
alignItems: 'flex-end',
},
subjectDate: {
...typography.caption,
color: c.textSecondary,
},
subjectSize: {
...typography.caption,
color: c.textMuted,
marginTop: 2,
},
// Sender block
senderBlock: {
@@ -931,6 +1241,19 @@ function makeStyles(c: ThemePalette) {
senderRecipientsLabel: {
color: c.textMuted,
},
senderMeta: {
alignItems: 'flex-end',
paddingTop: 1,
},
senderDate: {
...typography.caption,
color: c.textSecondary,
},
senderTime: {
...typography.caption,
color: c.textMuted,
marginTop: 2,
},
// Attachments
attachmentsBlock: {
@@ -989,6 +1312,23 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.background,
},
// Loading skeleton
skeletonBone: {
backgroundColor: c.surfaceHover,
borderRadius: radius.xs,
},
skeletonAvatar: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: radius.full,
backgroundColor: c.surfaceHover,
},
skeletonBody: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
gap: spacing.sm,
},
// Bottom bar
bottomBar: {
position: 'absolute',
+187 -87
View File
@@ -16,7 +16,7 @@ import {
import {
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download,
} from 'lucide-react-native';
// expo-document-picker is loaded lazily on first upload. Its native module
@@ -38,9 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
}
import {
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, isCrossAccountId,
isFolder, renameFileNode, supportsSharing, uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import type { FileNode } from '../api/types';
@@ -48,9 +47,9 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet';
// A file row carries the resolved display name alongside the original
// server-side name (the path-encoded `node.name` field).
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
displayName: string;
}
@@ -108,7 +107,10 @@ export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [path, setPath] = useState<string[]>([]);
// Folder navigation stack. Hierarchy is real parentId nesting (#379) — each
// entry is the folder's node id (namespaced "accountId:nodeId" inside a
// shared-with-me subtree) plus its name for the breadcrumb.
const [path, setPath] = useState<{ id: string; name: string }[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -124,6 +126,7 @@ export default function FilesScreen() {
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
@@ -137,8 +140,13 @@ export default function FilesScreen() {
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const selectionMode = selection.size > 0;
const prefix = useMemo(() => pathToPrefix(path), [path]);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
// True while browsing inside a folder another principal shared with us.
// Creates/uploads would have to route to the owner's account, which this
// screen doesn't do (matches the webmail's Files app); writes are hidden.
const inSharedSubtree = isCrossAccountId(currentParentId);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1].name;
const sharingEnabled = supportsSharing();
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
@@ -146,7 +154,9 @@ export default function FilesScreen() {
else setLoading(true);
setError(null);
try {
const nodes = await getAllFileNodes();
// One fetch across all accessible accounts: own files plus nodes other
// principals shared with us (tagged isShared, ids namespaced).
const nodes = await getAllFileNodesAcrossAccounts();
setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load files');
@@ -162,33 +172,49 @@ export default function FilesScreen() {
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that no longer exists (it was deleted, or
// we never had it), walk back up until we land on one that does. The check
// looks for a directory node whose serverName matches the encoded path.
// If we navigated into a folder that no longer exists (deleted, or its
// share was revoked), cut the stack back to the deepest ancestor that does.
useEffect(() => {
if (allNodes.length === 0) return;
setPath((prev) => {
let next = prev;
while (next.length > 0) {
const parentPrefix = pathToPrefix(next.slice(0, -1));
const expectedName = parentPrefix + next[next.length - 1];
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
if (exists) break;
next = next.slice(0, -1);
const byId = new Map(allNodes.map((n) => [n.id, n]));
let cut = prev.length;
for (let i = 0; i < prev.length; i++) {
const node = byId.get(prev[i].id);
if (!node || !isFolder(node)) {
cut = i;
break;
}
}
return next.length === prev.length ? prev : next;
return cut === prev.length ? prev : prev.slice(0, cut);
});
}, [allNodes]);
const visibleFiles = useMemo<FileRow[]>(() => {
const rows = allNodes
.filter((n) => isDirectChildOfPrefix(n, prefix))
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
let nodes: FileNode[];
if (currentParentId === null) {
// Root: our own top-level nodes, plus the roots of every shared-with-me
// subtree (a shared node whose parent isn't visible to us).
const idSet = new Set(allNodes.map((n) => n.id));
nodes = allNodes.filter((n) =>
n.isShared
? n.parentId == null || !idSet.has(n.parentId)
: (n.parentId ?? null) === null,
);
} else {
nodes = allNodes.filter((n) => (n.parentId ?? null) === currentParentId);
}
const rows = nodes
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
const dir = sortDir === 'desc' ? -1 : 1;
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
// Own content first, shared-with-me entries after (root only).
const aShared = a.isShared ? 1 : 0;
const bShared = b.isShared ? 1 : 0;
if (aShared !== bShared) return aShared - bShared;
const aDir = isFolder(a) ? 0 : 1;
const bDir = isFolder(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
@@ -203,15 +229,18 @@ export default function FilesScreen() {
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, currentParentId, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
const toggleSelect = useCallback((id: string) => {
// Shared-with-me rows stay out of multi-select: batch delete routes to our
// own account and would fail (or worse, mismatch) on namespaced ids.
const toggleSelect = useCallback((row: FileRow) => {
if (row.isShared) return;
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
}, []);
@@ -220,7 +249,7 @@ export default function FilesScreen() {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(row.blobId, row.displayName, row.type);
await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -232,7 +261,7 @@ export default function FilesScreen() {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(row.blobId, row.displayName, row.type);
await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -243,11 +272,11 @@ export default function FilesScreen() {
const handleRowPress = useCallback(
(row: FileRow) => {
if (selectionMode) {
toggleSelect(row.id);
toggleSelect(row);
return;
}
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
if (isFolder(row)) {
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
return;
}
void previewFile(row);
@@ -256,7 +285,7 @@ export default function FilesScreen() {
);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row.id);
toggleSelect(row);
}, [toggleSelect]);
const goBack = useCallback(() => {
@@ -277,19 +306,19 @@ export default function FilesScreen() {
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes(PATH_SEP) || name.includes('/')) {
if (name.includes('/')) {
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, path);
await createFolder(name, currentParentId);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, path, loadFiles]);
}, [newFolderName, currentParentId, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
@@ -298,7 +327,7 @@ export default function FilesScreen() {
setRenameTarget(null);
return;
}
if (name.includes(PATH_SEP) || name.includes('/')) {
if (name.includes('/')) {
Alert.alert('Invalid name', 'Names cannot contain "/".');
return;
}
@@ -306,12 +335,12 @@ export default function FilesScreen() {
setRenameTarget(null);
setRenameValue('');
try {
await renameFileNode(target, name, path, allNodes);
await renameFileNode(target.id, name);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
}, [renameTarget, renameValue, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
@@ -341,7 +370,7 @@ export default function FilesScreen() {
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
path,
currentParentId,
);
await loadFiles('refresh');
} catch (e) {
@@ -349,7 +378,7 @@ export default function FilesScreen() {
} finally {
setUploading(false);
}
}, [uploading, path, loadFiles]);
}, [uploading, currentParentId, loadFiles]);
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
@@ -362,12 +391,13 @@ export default function FilesScreen() {
setConfirmDelete(null);
clearSelection();
try {
await deleteWithDescendants(targets, allNodes);
// The server removes folder descendants (onDestroyRemoveChildren).
await deleteFileNodes(targets.map((t) => t.id));
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
}
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
}, [confirmDelete, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = path.length > 0;
@@ -409,27 +439,31 @@ export default function FilesScreen() {
</Text>
</View>
<View style={styles.headerActions}>
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
{!inSharedSubtree ? (
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
) : null}
{!inSharedSubtree ? (
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
) : null}
<Pressable
onPress={toggleView}
hitSlop={8}
@@ -442,7 +476,7 @@ export default function FilesScreen() {
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{['Files', ...path].join(' / ')}
{['Files', ...path.map((p) => p.name)].join(' / ')}
</Text>
) : null}
</View>
@@ -450,13 +484,14 @@ export default function FilesScreen() {
};
const renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const isDir = isFolder(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
return (
<Pressable
onPress={() => handleRowPress(item)}
@@ -470,10 +505,22 @@ export default function FilesScreen() {
>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.displayName}
</Text>
<View style={styles.fileNameRow}>
<Text style={styles.fileName} numberOfLines={1}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={13} color={c.primary} />
) : sharedOut ? (
<Users size={13} color={c.primary} />
) : null}
</View>
<View style={styles.fileMetaRow}>
{item.isShared && item.accountName ? (
<Text style={styles.fileMeta} numberOfLines={1}>
Shared by {item.accountName}
</Text>
) : null}
{item.size != null && !isDir ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
@@ -501,12 +548,13 @@ export default function FilesScreen() {
};
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const isDir = isFolder(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
return (
<Pressable
onPress={() => handleRowPress(item)}
@@ -523,10 +571,21 @@ export default function FilesScreen() {
) : (
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.displayName}
</Text>
{item.size != null && !isDir ? (
<View style={styles.gridNameRow}>
<Text style={styles.gridName} numberOfLines={2}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={12} color={c.primary} />
) : sharedOut ? (
<Users size={12} color={c.primary} />
) : null}
</View>
{item.isShared && item.accountName ? (
<Text style={styles.gridMeta} numberOfLines={1}>
Shared by {item.accountName}
</Text>
) : item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
@@ -627,6 +686,7 @@ export default function FilesScreen() {
<ActionsSheet
target={actionsTarget}
sharingEnabled={sharingEnabled}
onClose={() => setActionsTarget(null)}
onPreview={(r) => {
setActionsTarget(null);
@@ -636,6 +696,10 @@ export default function FilesScreen() {
setActionsTarget(null);
void downloadFile(r);
}}
onShare={(r) => {
setActionsTarget(null);
setShareTarget(r);
}}
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(r);
@@ -647,6 +711,12 @@ export default function FilesScreen() {
}}
/>
<ShareSheet
node={shareTarget}
onClose={() => setShareTarget(null)}
onChanged={() => void loadFiles('refresh')}
/>
<Dialog
visible={confirmDelete != null}
title={
@@ -722,18 +792,24 @@ function PromptModal(props: PromptModalProps) {
interface ActionsSheetProps {
target: FileRow | null;
sharingEnabled: boolean;
onClose: () => void;
onPreview: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onShare: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
function ActionsSheet({ target, sharingEnabled, onClose, onPreview, onDownload, onShare, onRename, onDelete }: ActionsSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
const isDir = isDirectory(target);
const isDir = isFolder(target);
// Owned nodes report full rights (or no myRights at all); shared-with-me
// nodes are managed by their owner, and write ops would have to route to
// the owner's account — so they only get preview/download here.
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
@@ -755,14 +831,24 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
<Text style={styles.actionLabel}>Save to device</Text>
</Pressable>
) : null}
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
{canShare ? (
<Pressable style={styles.action} onPress={() => onShare(target)}>
<Users size={18} color={c.text} />
<Text style={styles.actionLabel}>Sharing & access</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
) : null}
</View>
</TouchableWithoutFeedback>
</View>
@@ -856,9 +942,15 @@ function makeStyles(c: ThemePalette) {
fileInfo: {
flex: 1,
},
fileNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
fileName: {
fontSize: typography.body.fontSize,
color: c.text,
flexShrink: 1,
},
fileMetaRow: {
flexDirection: 'row',
@@ -886,10 +978,18 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.xs,
borderRadius: radius.md,
},
gridNameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
maxWidth: '100%',
},
gridName: {
fontSize: typography.caption.fontSize,
color: c.text,
textAlign: 'center',
flexShrink: 1,
},
gridMeta: {
fontSize: typography.small.fontSize,
+51 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [scannerVisible, setScannerVisible] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
@@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleScanned = async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
return;
}
try {
const before = useAuthStore.getState().isAuthenticated;
if (payload.kind === 'connect') {
// Server-bootstrap QR: fill the field and run the normal browser
// handoff so the user still authenticates in the webmail.
setServerUrl(payload.webmailUrl);
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
} else {
// Cross-device pairing QR: redeem the one-time code for tokens, no
// browser round-trip needed.
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
}
const after = useAuthStore.getState().isAuthenticated;
if ((after && !before) || (isAddMode && after)) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
</View>
</View>
</KeyboardAvoidingView>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
/>
</SafeAreaView>
);
}
+5 -2
View File
@@ -6,7 +6,7 @@ import {
Palette, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug, Download,
type LucideIcon,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -35,6 +35,7 @@ import { ContentSendersSettings } from '../components/settings/ContentSendersSet
import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
@@ -42,7 +43,7 @@ type Tab =
| 'account' | 'language' | 'notifications'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords'
| 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads'
| 'security' | 'encryption' | 'content_senders'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
@@ -88,6 +89,7 @@ const TABS: TabDef[] = [
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
{ id: 'downloads', label: 'Downloads', icon: Download, group: 'mail', implemented: true },
// Privacy & Security
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
@@ -122,6 +124,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
templates: TemplateSettings,
folders: FolderSettings,
keywords: KeywordSettings,
downloads: DownloadsSettings,
security: AccountSecuritySettings,
encryption: SmimeSettings,
content_senders: ContentSendersSettings,
+28 -14
View File
@@ -10,6 +10,9 @@ import SenderAvatar from '../components/SenderAvatar';
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -19,22 +22,15 @@ function senderName(email: UnifiedEmail): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
function formatRelativeTime(date: Date): string {
const diffMin = Math.floor((Date.now() - date.getTime()) / 60000);
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}d`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function UnifiedInboxScreen({ navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accounts = useAccountStore((s) => s.accounts);
const switchAccount = useAuthStore((s) => s.switchAccount);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
const locale = useLocaleStore((s) => s.locale);
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
@@ -50,13 +46,13 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
const load = React.useCallback(async () => {
setLoading(true);
try {
const result = await fetchUnifiedInbox(accountIds);
const result = await fetchUnifiedInbox(accountIds, 25, { includeGroup });
setEmails(result.emails);
setErrors(result.errors);
} finally {
setLoading(false);
}
}, [accountIds]);
}, [accountIds, includeGroup]);
React.useEffect(() => {
void load();
@@ -77,6 +73,9 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
// Group/shared messages live under another JMAP account in the
// same session; pass it so the thread opens against the right one.
jmapAccountId: email.isShared ? email.jmapAccountId : undefined,
});
} finally {
setOpening(false);
@@ -112,7 +111,7 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
@@ -123,6 +122,13 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
{acc.email || acc.username}
</Text>
)}
{item.isShared && (
<View style={styles.sharedBadge}>
<Text style={styles.sharedBadgeText} numberOfLines={1}>
{item.sharedLabel || 'Shared'}
</Text>
</View>
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
@@ -229,6 +235,14 @@ function makeStyles(c: ThemePalette) {
time: { ...typography.caption, color: c.textMuted },
subject: { ...typography.body, color: c.text },
account: { ...typography.caption, color: c.textMuted },
sharedBadge: {
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: radius.full,
backgroundColor: c.muted,
maxWidth: 120,
},
sharedBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
preview: { ...typography.caption, color: c.textMuted },
});
}
+95 -5
View File
@@ -7,6 +7,7 @@ vi.mock('../../api/calendar', () => ({
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvents: vi.fn(),
setDefaultCalendar: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -37,6 +38,7 @@ const mockGetEvents = calendarApi.getEvents as ReturnType<typeof vi.fn>;
const mockCreateEvent = calendarApi.createEvent as ReturnType<typeof vi.fn>;
const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
@@ -99,6 +101,70 @@ describe('calendar-store', () => {
expect(useCalendarStore.getState().events).toEqual([]);
expect(mockGetEvents).not.toHaveBeenCalled();
});
it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => {
// A shared calendar lives in another account; its event id ("ev1") can
// collide with one of the user's own events, so the store must namespace
// it. Mirrors webmail's mapServerEventToStoreEvent.
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'Personal' } as any,
{ id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any,
],
});
mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) =>
accountId === 'acc-2' ? ['ev1'] : [],
);
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
accountId === 'acc-2'
? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }]
: [],
);
await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31');
const stored = useCalendarStore.getState().events;
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe('acc-2:ev1');
expect(stored[0].originalId).toBe('ev1');
expect(stored[0].accountId).toBe('acc-2');
});
it('routes queries by account when own and shared calendars share a raw id', async () => {
// The user's own "default" calendar and a family group's "default"
// calendar collide on the raw JMAP id. The shared one is namespaced
// (`acc-2:default`, originalId "default"); the query must use the raw id
// against acc-2, and the returned event's calendarIds must be remapped to
// the namespaced store id so it isn't hidden with the personal calendar.
useCalendarStore.setState({
calendars: [
{ id: 'default', name: 'Personal' } as any,
{ id: 'acc-2:default', originalId: 'default', name: 'Family', accountId: 'acc-2', isShared: true } as any,
],
});
const queried: Record<string, string[]> = {};
mockQueryEvents.mockImplementation(async (ids: string[], _a: string, _b: string, accountId?: string) => {
queried[accountId ?? 'primary'] = ids;
return accountId === 'acc-2' ? ['fam1'] : [];
});
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
accountId === 'acc-2'
? [{ id: 'fam1', title: 'Soccer', start: '2026-03-20T15:00:00', calendarIds: { default: true } }]
: [],
);
await useCalendarStore.getState().fetchEvents(['default', 'acc-2:default'], '2026-03-01', '2026-03-31');
// The shared account is queried with the raw calendar id, not the prefix.
expect(queried['acc-2']).toEqual(['default']);
const ev = useCalendarStore.getState().events[0];
expect(ev.calendarIds).toEqual({ 'acc-2:default': true });
// Hiding the personal "default" must not hide the family event.
useCalendarStore.setState({ hiddenCalendarIds: ['default'] });
const visible = selectVisibleEvents(useCalendarStore.getState());
expect(visible.map((e) => e.id)).toContain('acc-2:fam1');
});
});
describe('ensureRange', () => {
@@ -110,7 +176,8 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31');
// 4th arg is the owning account — undefined for primary-account calendars.
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
it('should skip refetch when loaded range covers requested', async () => {
@@ -134,7 +201,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-04-01', '2026-04-30');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30', undefined);
});
it('should fetch calendars first when none are loaded', async () => {
@@ -144,7 +211,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(mockGetCalendars).toHaveBeenCalled();
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
});
@@ -273,8 +340,31 @@ describe('calendar-store', () => {
);
// The third arg is the iMIP scheduling flag — undefined here since the
// event has no participants to notify.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
// event has no participants to notify. The fourth is the owning
// account — undefined for primary-account events.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined);
});
});
describe('setDefaultCalendar', () => {
it('flips isDefault to the chosen calendar within the same account', async () => {
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'A', isDefault: true } as any,
{ id: 'cal-2', name: 'B' } as any,
{ id: 'cal-3', name: 'Shared', isShared: true, accountId: 'acc-2', isDefault: true } as any,
],
});
mockSetDefaultCalendar.mockResolvedValue(undefined);
await useCalendarStore.getState().setDefaultCalendar('cal-2');
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined);
const calendars = useCalendarStore.getState().calendars;
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
// Defaults in other accounts are untouched.
expect(calendars.find((c) => c.id === 'cal-3')?.isDefault).toBe(true);
});
});
+37
View File
@@ -14,13 +14,48 @@ vi.mock('../../api/email', () => ({
getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
setKeywordsForEmails: vi.fn(),
moveEmail: vi.fn(),
moveEmails: vi.fn(),
archiveEmails: vi.fn(),
restoreEmailMailboxes: vi.fn(),
setEmailMailboxes: vi.fn(),
destroyEmails: vi.fn(),
deleteEmail: vi.fn(),
deleteEmails: vi.fn(),
searchEmails: vi.fn(),
}));
// The mutations now route through the offline outbox. In tests we want the
// "online, nothing queued" fast path: run the supplied online runner (or the
// op's primitive) immediately so the existing api-call assertions still hold,
// without pulling in network-store / NetInfo.
vi.mock('../outbox-store', async () => {
const api = await import('../../api/email') as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId]);
};
const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise<void>) => {
if (onlineRun) await onlineRun();
else await Promise.all(ops.map(runOp));
return { queued: false };
};
return {
applyOrQueueBatch,
applyOrQueue: async (op: any, onlineRun?: () => Promise<void>) => applyOrQueueBatch([op], onlineRun),
useOutboxStore: {
getState: () => ({
entries: [],
count: () => 0,
setAccount: vi.fn(async () => undefined),
flush: vi.fn(async () => undefined),
}),
},
};
});
// settings-store transitively pulls in jmap-client / expo-secure-store, which
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
@@ -44,6 +79,8 @@ vi.mock('../offline-cache-store', () => ({
has: () => false,
get: vi.fn(async () => null),
put: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
}),
},
}));
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// NetInfo is a native module; stub it so network-store loads under node. The
// store defaults to online, which we flip per-test via setState.
vi.mock('@react-native-community/netinfo', () => ({
default: {
addEventListener: () => () => undefined,
fetch: async () => ({ isConnected: true, isInternetReachable: true }),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { isConnected: true },
}));
const setEmailKeywords = vi.fn(async (..._a: any[]) => undefined);
const setEmailMailboxes = vi.fn(async (..._a: any[]) => undefined);
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
vi.mock('../../api/email', () => ({
setEmailKeywords: (...a: any[]) => setEmailKeywords(...a),
setEmailMailboxes: (...a: any[]) => setEmailMailboxes(...a),
destroyEmails: (...a: any[]) => destroyEmails(...a),
}));
import { useOutboxStore, applyOrQueue } from '../outbox-store';
import { useNetworkStore } from '../network-store';
const ACCOUNT = 'acc-1';
beforeEach(async () => {
vi.clearAllMocks();
useNetworkStore.setState({ online: true, connected: true });
// Detach then attach a clean bucket (AsyncStorage is the in-memory mock).
await useOutboxStore.getState().setAccount(null);
await useOutboxStore.getState().clear();
await useOutboxStore.getState().setAccount(ACCOUNT);
await useOutboxStore.getState().clear();
});
describe('outbox enqueue + coalescing', () => {
it('coalesces repeated keyword ops for the same email (last-write-wins)', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1);
expect(entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
});
it('keeps keyword and mailbox ops for the same email separate', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
expect(useOutboxStore.getState().entries).toHaveLength(2);
});
it('destroy supersedes pending edits for that email', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
store.enqueue({ kind: 'destroy', emailId: 'e1' });
const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1);
expect(entries[0].op.kind).toBe('destroy');
});
it('ignores further edits once a destroy is queued', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'destroy', emailId: 'e1' });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].op.kind).toBe('destroy');
});
});
describe('applyOrQueue', () => {
it('runs immediately when online with an empty queue', async () => {
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(false);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('prefers the supplied online runner over the primitive', async () => {
const onlineRun = vi.fn(async () => undefined);
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } }, onlineRun);
expect(onlineRun).toHaveBeenCalledOnce();
expect(setEmailMailboxes).not.toHaveBeenCalled();
});
it('queues instead of running when offline', async () => {
useNetworkStore.setState({ online: false });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
it('queues a later op for the same email to preserve order', async () => {
useNetworkStore.setState({ online: false });
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } });
useNetworkStore.setState({ online: true });
// Now online, but an op is already queued for e1 → must queue, not run.
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled();
});
it('surfaces a non-transient (server) error to the caller', async () => {
const onlineRun = vi.fn(async () => { throw new Error('JMAP request failed: 403'); });
await expect(
applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } }, onlineRun),
).rejects.toThrow('403');
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('queues when the online attempt fails with a connectivity error', async () => {
const err = new Error('Network request failed');
err.name = 'NetworkError';
const onlineRun = vi.fn(async () => { throw err; });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }, onlineRun);
expect(result.queued).toBe(true);
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
});
describe('flush', () => {
it('replays queued ops in order and clears them', async () => {
useNetworkStore.setState({ online: false });
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } });
store.enqueue({ kind: 'destroy', emailId: 'e3' });
useNetworkStore.setState({ online: true });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true });
expect(destroyEmails).toHaveBeenCalledWith(['e3']);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('does nothing while offline', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
it('stops and retains the op on a transient failure', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
const err = new Error('Network request failed');
err.name = 'NetworkError';
setEmailKeywords.mockRejectedValueOnce(err);
await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].lastError).toContain('Network');
});
it('drops a poison op after repeated server rejections', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400'));
// MAX_ATTEMPTS is 5; flush bumps one attempt per run.
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
});
+75 -26
View File
@@ -5,8 +5,9 @@ import { useAccountStore } from './account-store';
import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -40,6 +41,7 @@ export interface AuthState {
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -55,6 +57,7 @@ function clearAllFeatureStores(): void {
useEmailStore.getState().clearAllAccounts();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
useFilterStore.getState().clearState();
}
// Drop the named account from the email cache, then reset the (per-session,
@@ -72,6 +75,7 @@ function clearAccountFeatureStores(accountId: string | null): void {
// follow-up.
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
useFilterStore.getState().clearState();
}
function refetchFeatureStores(): void {
@@ -94,6 +98,43 @@ function refetchFeatureStores(): void {
}
}
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
// pairing both end here). Bootstraps a JMAP session from the token bundle,
// registers the account, and flips the store to connected. Throws on failure
// so the caller can surface a flow-specific error.
async function completeOAuthHandoff(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
): Promise<void> {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
}
function applyConnectedState(
set: (partial: Partial<AuthState>) => void,
session: JMAPSession,
@@ -197,31 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
@@ -234,6 +251,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaPairing: async (webmailUrl, code, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await redeemPairingCode(webmailUrl, code);
} catch (err) {
const message = err instanceof Error ? err.message : 'Pairing failed';
set({ isLoading: false, error: message });
throw err;
}
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
// future server change can't silently mis-route credentials here.
if (result.flow !== 'oauth') {
set({ isLoading: false, error: 'Unexpected pairing response' });
throw new Error('Unexpected pairing response');
}
try {
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'Pairing sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
+126 -18
View File
@@ -12,6 +12,7 @@ import {
batchCreateEvents as apiBatchCreateEvents,
rsvpEvent as apiRsvpEvent,
createCalendar as apiCreateCalendar,
setDefaultCalendar as apiSetDefaultCalendar,
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion';
@@ -24,6 +25,37 @@ function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boole
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
// Map a server CalendarEvent onto its store representation. For events from a
// shared/group account the JMAP id is only unique within that account, so we
// namespace `id` (stashing the real id in originalId) and remap the event's
// `calendarIds` from raw server ids onto the namespaced store calendar ids so
// visibility filtering and per-calendar colour lookup line up. Events from the
// primary account are returned unchanged. Mirrors webmail's
// mapServerEventToStoreEvent.
function mapServerEventToStoreEvent(
event: CalendarEvent,
calendars: Calendar[],
accountId?: string,
): CalendarEvent {
if (!accountId) return event;
const mapped: Record<string, boolean> = {};
for (const [calId, included] of Object.entries(event.calendarIds || {})) {
const cal = calendars.find(
(c) => (c.originalId || c.id) === calId && c.accountId === accountId,
);
mapped[cal?.id || calId] = included;
}
return {
...event,
id: `${accountId}:${event.id}`,
originalId: event.id,
originalCalendarIds: event.calendarIds,
calendarIds: Object.keys(mapped).length > 0 ? mapped : event.calendarIds,
accountId,
isShared: true,
};
}
export interface LoadedRange {
after: string;
before: string;
@@ -57,6 +89,7 @@ export interface CalendarState {
) => Promise<void>;
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
createCalendar: (name: string, color?: string) => Promise<Calendar>;
setDefaultCalendar: (id: string) => Promise<void>;
// Tasks
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
@@ -131,8 +164,37 @@ export const useCalendarStore = create<CalendarState>()(
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
// Group the requested calendars by owning account: the primary account
// (calendars without an accountId tag) plus one group per shared
// account, since CalendarEvent/query is scoped to a single account. The
// incoming ids are store ids (shared calendars are namespaced
// `${accountId}:${id}`); map them back to the raw server ids the query
// filter expects.
const calendars = get().calendars;
const byId = new Map(calendars.map((c) => [c.id, c]));
const groups = new Map<string | undefined, string[]>();
for (const id of calendarIds) {
const cal = byId.get(id);
const accountId = cal?.accountId;
const serverId = cal?.originalId || id;
const group = groups.get(accountId);
if (group) group.push(serverId);
else groups.set(accountId, [serverId]);
}
if (groups.size === 0) groups.set(undefined, []);
const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) {
try {
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
if (eventIds.length === 0) continue;
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
// A failing shared account must not hide the user's own events.
if (!accountId) throw err;
}
}
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events
// have a `start`; tasks are surfaced separately so they don't pollute the
// grid (they're shown in the task list with their due date instead).
@@ -176,12 +238,19 @@ export const useCalendarStore = create<CalendarState>()(
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
const calendarChanged = 'Calendar' in accountChanges;
const eventChanged = 'CalendarEvent' in accountChanges;
// Watch the primary account plus every shared account we show calendars
// from, so edits the owner makes to a shared calendar refresh the view.
const known = new Set<string>([jmapClient.accountId]);
for (const cal of get().calendars) {
if (cal.accountId) known.add(cal.accountId);
}
let calendarChanged = false;
let eventChanged = false;
for (const [accountId, types] of Object.entries(change.changed ?? {})) {
if (!known.has(accountId)) continue;
if ('Calendar' in types) calendarChanged = true;
if ('CalendarEvent' in types) eventChanged = true;
}
if (!calendarChanged && !eventChanged) return;
if (calendarChanged) {
@@ -193,12 +262,22 @@ export const useCalendarStore = create<CalendarState>()(
},
createEvent: async (event, calendarId) => {
// Shared calendars live in the owner's account — route the create there,
// and against the calendar's raw server id (calendarId is the namespaced
// store id for shared calendars).
const calendars = get().calendars;
const cal = calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const created = await apiCreateEvent(
event,
calendarId,
cal?.originalId || calendarId,
hasSchedulingParticipants(event) ? true : undefined,
accountId,
);
set({ events: [...get().events, created] });
// Map the same way fetchEvents does so the optimistic insert doesn't
// collide with the user's own events and stays visible under the right
// calendar filter.
set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] });
return created;
},
@@ -210,7 +289,7 @@ export const useCalendarStore = create<CalendarState>()(
// carry participants.
const schedule =
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
await apiUpdateEvent(realId, changes, schedule ? true : undefined);
await apiUpdateEvent(realId, changes, schedule ? true : undefined, storeEvent?.accountId);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
});
@@ -222,6 +301,7 @@ export const useCalendarStore = create<CalendarState>()(
await apiDeleteEvents(
[realId],
hasSchedulingParticipants(storeEvent) ? true : undefined,
storeEvent?.accountId,
);
set({ events: get().events.filter((e) => e.id !== id) });
},
@@ -232,7 +312,7 @@ export const useCalendarStore = create<CalendarState>()(
}
const storeEvent = get().events.find((e) => e.id === eventId);
const realId = storeEvent?.originalId || eventId;
await apiRsvpEvent(realId, participantId, status, replyTo);
await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId);
set({
events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -249,20 +329,26 @@ export const useCalendarStore = create<CalendarState>()(
importEvents: async (events, calendarId) => {
if (events.length === 0) return 0;
// Shared calendars live in the owner's account and carry a namespaced
// store id — resolve the raw server id + owning account so dedup and
// create target the right place.
const cal = get().calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const serverCalendarId = cal?.originalId || calendarId;
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
// already exists; create the rest. (We don't attempt cross-calendar linking
// on mobile — a duplicate is simply skipped.)
let toCreate = events;
try {
const existingIds = await queryEvents([], '', '');
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
const existingIds = await queryEvents([], '', '', accountId);
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
} catch {
// Couldn't dedupe — proceed and let the server reject genuine dupes.
}
if (toCreate.length === 0) return 0;
const count = await apiBatchCreateEvents(toCreate, calendarId);
const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId);
await get().refresh();
return count;
},
@@ -273,15 +359,37 @@ export const useCalendarStore = create<CalendarState>()(
return created;
},
setDefaultCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
set({
calendars: get().calendars.map((c) => {
if (c.id === id) return { ...c, isDefault: true };
// Only one default per account — clear the flag on siblings within
// the same account scope.
if (c.isDefault && (c.accountId ?? null) === (cal?.accountId ?? null)) {
return { ...c, isDefault: false };
}
return c;
}),
});
},
createTask: async (task, calendarId) => {
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
const cal = get().calendars.find((c) => c.id === calendarId);
await apiCreateEvent(
{ ...task, '@type': 'Task' },
cal?.originalId || calendarId,
undefined,
cal?.accountId,
);
await get().refresh();
},
updateTask: async (id, changes) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes);
await apiUpdateEvent(realId, changes, undefined, task?.accountId);
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
},
@@ -298,7 +406,7 @@ export const useCalendarStore = create<CalendarState>()(
deleteTask: async (id) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiDeleteEvents([realId]);
await apiDeleteEvents([realId], undefined, task?.accountId);
set({ tasks: get().tasks.filter((t) => t.id !== id) });
},
+8 -5
View File
@@ -376,9 +376,9 @@ export function selectGroups(state: ContactsState): ContactCard[] {
return state.contacts.filter(isGroup);
}
export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: string; count: number }> {
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
const counts = new Map<string, number>();
for (const contact of state.contacts) {
for (const contact of contacts) {
for (const kw of getContactKeywords(contact)) {
counts.set(kw, (counts.get(kw) || 0) + 1);
}
@@ -388,10 +388,13 @@ export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: strin
.sort((a, b) => a.keyword.localeCompare(b.keyword));
}
export function selectAddressBooksWithCount(state: ContactsState): Array<AddressBook & { count: number }> {
return state.addressBooks.map((book) => ({
export function selectAddressBooksWithCount(
addressBooks: AddressBook[],
contacts: ContactCard[],
): Array<AddressBook & { count: number }> {
return addressBooks.map((book) => ({
...book,
count: state.contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
}));
}
+212 -35
View File
@@ -14,6 +14,7 @@ import {
getEmailsWithState,
getEmailChanges,
getFullEmail,
importEmailBlob,
setEmailKeywords,
setKeywordsForEmails,
moveEmail,
@@ -28,6 +29,30 @@ import { toWildcardQuery } from '../lib/search-utils';
import { generateAccountId } from '../lib/account-utils';
import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store';
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
// Keep the offline body cache consistent with an optimistic/queued mutation so
// re-opening a message while offline shows the change. Fire-and-forget.
function patchCache(id: string, changes: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> }): void {
void useOfflineCacheStore.getState().patch(id, changes);
}
function dropFromCache(ids: string[]): void {
void useOfflineCacheStore.getState().remove(ids);
}
// Compute an email's full mailboxIds map after removing one mailbox and adding
// another — the idempotent target the outbox replays for a move/trash.
function mailboxesAfterMove(
current: Record<string, boolean> | undefined,
fromMailboxId: string | null,
toMailboxId: string,
): Record<string, boolean> {
const next: Record<string, boolean> = {};
for (const [id, present] of Object.entries(current ?? {})) {
if (present && id !== fromMailboxId) next[id] = true;
}
next[toMailboxId] = true;
return next;
}
// True only when the JMAP client is actually serving the email-store's active
// account. During an account switch there's a window between
@@ -124,9 +149,13 @@ export interface EmailState {
selectMailbox: (mailboxId: string) => Promise<void>;
loadMoreEmails: () => Promise<void>;
refreshEmails: () => Promise<void>;
importEmails: (
files: { uri: string; name: string; mimeType?: string }[],
mailboxId: string,
) => Promise<{ imported: number; failed: number }>;
handleStateChange: (change: StateChange) => Promise<void>;
getEmailDetail: (id: string) => Promise<Email>;
markRead: (emailId: string) => Promise<void>;
getEmailDetail: (id: string, accountId?: string) => Promise<Email>;
markRead: (emailId: string, accountId?: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
togglePin: (emailId: string, pinned: boolean) => Promise<void>;
@@ -347,6 +376,11 @@ export const useEmailStore = create<EmailState>()(
// and-forget — the cache returns empty until hydration completes,
// which is the correct degraded behaviour.
void useOfflineCacheStore.getState().setAccount(accountId);
// Load the new account's outbox and try to drain it (no-op when offline or
// the JMAP client isn't serving this account yet).
void useOutboxStore.getState().setAccount(accountId).then(() => {
void useOutboxStore.getState().flush();
});
},
removeAccount: (accountId) => {
@@ -369,6 +403,7 @@ export const useEmailStore = create<EmailState>()(
pendingUndo: null,
});
void useOfflineCacheStore.getState().setAccount(null);
void useOutboxStore.getState().setAccount(null);
} else {
set({ accountSnapshots: rest });
}
@@ -393,6 +428,7 @@ export const useEmailStore = create<EmailState>()(
loading: false,
});
void useOfflineCacheStore.getState().setAccount(null);
void useOutboxStore.getState().setAccount(null);
},
fetchMailboxes: async () => {
@@ -572,6 +608,38 @@ export const useEmailStore = create<EmailState>()(
}
},
importEmails: async (files, mailboxId) => {
// Loaded lazily so the store module stays free of expo-file-system at
// import time (that native dep can't load in the test/SSR environment).
const { uploadBytes } = await import('../api/blob');
const { expandImportableEml } = await import('../lib/eml-import');
let imported = 0;
let failed = 0;
for (const file of files) {
try {
// A .eml expands to one message; a .zip to one per .eml it contains.
const emls = await expandImportableEml(file.uri, file.name, file.mimeType);
if (emls.length === 0) failed += 1;
for (const eml of emls) {
try {
const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822');
await importEmailBlob(blobId, mailboxId);
imported += 1;
} catch {
failed += 1;
}
}
} catch {
failed += 1;
}
}
// Surface freshly imported messages if we imported into the open mailbox.
if (imported > 0 && get().currentMailboxId === mailboxId) {
await get().refreshEmails();
}
return { imported, failed };
},
refreshEmails: async () => {
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
@@ -784,13 +852,14 @@ export const useEmailStore = create<EmailState>()(
void get().refreshEmails();
},
getEmailDetail: async (id) => {
getEmailDetail: async (id, accountId) => {
// Try the network first so the user sees fresh keywords/flags. If that
// fails (offline / server unreachable), fall back to the offline cache
// when the message is in it. Without the cache hit, propagate the error
// so the caller can surface it.
// so the caller can surface it. `accountId` targets a group/shared inbox
// message opened from the unified view.
try {
const fresh = await getFullEmail(id);
const fresh = await getFullEmail(id, accountId);
// Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState();
@@ -808,27 +877,36 @@ export const useEmailStore = create<EmailState>()(
}
},
markRead: async (emailId) => {
markRead: async (emailId, accountId) => {
const email = get().emails.find((e) => e.id === emailId);
const nextKeywords = { ...(email?.keywords ?? {}), $seen: true };
await setEmailKeywords(emailId, nextKeywords);
// A group/shared message opened from the unified inbox lives under another
// JMAP account and isn't in the active list/cache or the (account-scoped)
// offline queue — mark it read directly against its owning account.
if (accountId && !email) {
await setEmailKeywords(emailId, nextKeywords, accountId);
return;
}
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: nextKeywords } : e,
),
});
patchCache(emailId, { keywords: nextKeywords });
},
markUnread: async (emailId) => {
const email = get().emails.find((e) => e.id === emailId);
if (!email) return;
const { $seen, ...rest } = email.keywords;
await setEmailKeywords(emailId, rest);
await applyOrQueue({ kind: 'keywords', emailId, keywords: rest });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: rest } : e,
),
});
patchCache(emailId, { keywords: rest });
},
toggleStar: async (emailId, starred) => {
@@ -840,12 +918,13 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$flagged;
}
await setEmailKeywords(emailId, keywords);
await applyOrQueue({ kind: 'keywords', emailId, keywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
patchCache(emailId, { keywords });
},
togglePin: async (emailId, pinned) => {
@@ -857,20 +936,26 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$important;
}
await setEmailKeywords(emailId, keywords);
await applyOrQueue({ kind: 'keywords', emailId, keywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
patchCache(emailId, { keywords });
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const target = mailboxesAfterMove(email?.mailboxIds, fromMailboxId, toMailboxId);
await moveEmail(emailId, fromMailboxId, toMailboxId);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => moveEmail(emailId, fromMailboxId, toMailboxId),
);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
patchCache(emailId, { mailboxIds: target });
if (email && original) {
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
@@ -899,11 +984,17 @@ export const useEmailStore = create<EmailState>()(
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
await apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
// Online keeps the rich year/month auto-foldering. Offline degrades to the
// archive root (we can't create folders without a connection); the queued
// op replays as a plain move into Archive.
const { queued } = await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: { [archiveMailbox.id]: true } },
() => apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
),
);
set({
@@ -915,10 +1006,12 @@ export const useEmailStore = create<EmailState>()(
items: [{ email, originalMailboxIds: original }],
},
});
patchCache(emailId, { mailboxIds: { [archiveMailbox.id]: true } });
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render.
if (mode !== 'single') {
// mailbox list so the sidebar picks them up on the next render. Skip when
// the action was only queued (no folders were created offline).
if (mode !== 'single' && !queued) {
void get().fetchMailboxes();
}
},
@@ -945,9 +1038,26 @@ export const useEmailStore = create<EmailState>()(
if (destroy) {
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
// destroy branch even when the source folder isn't trash.
await apiDeleteEmail(emailId, trashMailboxId, trashMailboxId);
await applyOrQueue(
{ kind: 'destroy', emailId },
() => apiDeleteEmail(emailId, trashMailboxId, trashMailboxId),
);
dropFromCache([emailId]);
} else {
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
const target = mailboxesAfterMove(email?.mailboxIds, currentMailboxId, trashMailboxId);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => apiDeleteEmail(emailId, trashMailboxId, currentMailboxId),
);
// "Move to Trash and mark as read" (#323): when the user picked that
// delete action, also clear unread state for messages moved to trash.
if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) {
const nextKeywords = { ...email.keywords, $seen: true };
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
patchCache(emailId, { mailboxIds: target, keywords: nextKeywords });
} else {
patchCache(emailId, { mailboxIds: target });
}
}
set({ emails: get().emails.filter((e) => e.id !== emailId) });
@@ -982,12 +1092,16 @@ export const useEmailStore = create<EmailState>()(
const mode = useSettingsStore.getState().archiveMode;
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
const archiveTarget = { [archiveMailbox.id]: true };
await apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
mode,
mailboxes,
const { queued } = await applyOrQueueBatch(
targets.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, mailboxIds: archiveTarget })),
() => apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
mode,
mailboxes,
),
);
const removed = new Set(targets.map((e) => e.id));
@@ -1000,8 +1114,9 @@ export const useEmailStore = create<EmailState>()(
items,
},
});
for (const e of targets) patchCache(e.id, { mailboxIds: archiveTarget });
if (mode !== 'single') void get().fetchMailboxes();
if (mode !== 'single' && !queued) void get().fetchMailboxes();
},
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
@@ -1012,9 +1127,19 @@ export const useEmailStore = create<EmailState>()(
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
await apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId);
await applyOrQueueBatch(
targets.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId),
})),
() => apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId),
);
const removed = new Set(targets.map((e) => e.id));
for (const e of targets) {
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId) });
}
const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
emails: get().emails.filter((e) => !removed.has(e.id)),
@@ -1051,11 +1176,49 @@ export const useEmailStore = create<EmailState>()(
(destroy ? toDestroy : toTrash).push(e);
}
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
// "Move to Trash and mark as read" (#323): also clear unread state for the
// moved-to-trash messages when that delete action is selected.
const toMarkRead =
settings.deleteAction === 'trash-and-read'
? toTrash.filter((e) => !e.keywords?.$seen)
: [];
const markReadKeywords = new Map(
toMarkRead.map((e) => [e.id, { ...e.keywords, $seen: true }]),
);
const ops: OutboxOp[] = [
...toDestroy.map((e): OutboxOp => ({ kind: 'destroy', emailId: e.id })),
...toTrash.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
})),
...toMarkRead.map((e): OutboxOp => ({
kind: 'keywords',
emailId: e.id,
keywords: markReadKeywords.get(e.id)!,
})),
];
await applyOrQueueBatch(ops, async () => {
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
}
if (toMarkRead.length > 0) {
await setKeywordsForEmails(
toMarkRead.map((e) => ({ id: e.id, keywords: markReadKeywords.get(e.id)! })),
);
}
});
if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id));
for (const e of toTrash) {
patchCache(e.id, {
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}),
});
}
const removed = new Set(targets.map((e) => e.id));
@@ -1083,13 +1246,17 @@ export const useEmailStore = create<EmailState>()(
else delete keywords[token];
return { id: e.id, keywords };
});
await setKeywordsForEmails(updates);
await applyOrQueueBatch(
updates.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, keywords: u.keywords })),
() => setKeywordsForEmails(updates),
);
const byId = new Map(updates.map((u) => [u.id, u.keywords]));
set({
emails: get().emails.map((e) =>
byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e,
),
});
for (const u of updates) patchCache(u.id, { keywords: u.keywords });
},
undoLast: async () => {
@@ -1098,9 +1265,19 @@ export const useEmailStore = create<EmailState>()(
set({ pendingUndo: null });
try {
await restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
await applyOrQueueBatch(
entry.items.map((it): OutboxOp => ({
kind: 'mailboxes',
emailId: it.email.id,
mailboxIds: it.originalMailboxIds,
})),
() => restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
),
);
for (const it of entry.items) {
patchCache(it.email.id, { mailboxIds: it.originalMailboxIds });
}
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Undo failed' });
return;
+203
View File
@@ -0,0 +1,203 @@
import { create } from 'zustand';
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '../lib/sieve/types';
import { parseScript } from '../lib/sieve/parser';
import { generateScript } from '../lib/sieve/generator';
import {
createSieveScript,
getSieveCapabilities,
getSieveScriptContent,
getSieveScripts,
isSieveSupported,
updateSieveScript,
validateSieveScript,
} from '../api/sieve';
// Ported from the webmail's stores/filter-store.ts. The mobile client is a
// singleton (api/sieve drives `jmapClient` directly), so the actions drop the
// `client` argument the web store threads through.
interface FilterStore {
rules: FilterRule[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
sieveCapabilities: SieveCapabilities | null;
activeScriptId: string | null;
isOpaque: boolean;
rawScript: string;
vacationSettings: VacationSieveConfig | null;
externalRequires: string[];
fetchFilters: () => Promise<void>;
saveFilters: () => Promise<void>;
validateScript: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
deleteRule: (ruleId: string) => void;
reorderRules: (ruleIds: string[]) => void;
toggleRule: (ruleId: string) => void;
setRawScript: (content: string) => void;
setOpaqueScript: (content: string) => void;
resetToVisualBuilder: () => void;
clearState: () => void;
}
export const useFilterStore = create<FilterStore>()((set, get) => ({
rules: [],
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
sieveCapabilities: null,
activeScriptId: null,
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
fetchFilters: async () => {
if (!isSieveSupported()) {
set({ isSupported: false, isLoading: false });
return;
}
set({ isLoading: true, error: null, isSupported: true });
try {
set({ sieveCapabilities: getSieveCapabilities() });
const allScripts = await getSieveScripts();
// Skip the server-managed 'vacation' script (RFC 9661 §4) - it can only
// be modified via VacationResponse/set, not SieveScript/set.
const scripts = allScripts.filter((s) => s.name !== 'vacation');
const activeScript = scripts.find((s) => s.isActive) || scripts[0];
if (!activeScript) {
set({ isLoading: false, rules: [], activeScriptId: null, rawScript: '', isOpaque: false });
return;
}
set({ activeScriptId: activeScript.id });
const content = await getSieveScriptContent(activeScript.blobId);
set({ rawScript: content });
const result = parseScript(content);
set({
isLoading: false,
isOpaque: result.isOpaque,
rules: result.isOpaque ? [] : result.rules,
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch filters',
});
}
},
saveFilters: async () => {
set({ isSaving: true, error: null });
try {
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
const content = isOpaque
? rawScript
: generateScript(rules, vacationSettings || undefined, { externalRequires });
if (activeScriptId) {
await updateSieveScript(activeScriptId, content, true);
} else {
const script = await createSieveScript('filters', content, true);
set({ activeScriptId: script.id });
}
set({ isSaving: false, rawScript: content });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to save filters',
});
throw error;
}
},
validateScript: async (content) => validateSieveScript(content),
addRule: (rule) => {
// Insert new bulwark rules before external/opaque rules so Bulwark's
// managed section stays contiguous.
set((state) => {
const bulwark = state.rules.filter((r) => !r.origin || r.origin === 'bulwark');
const external = state.rules.filter((r) => r.origin === 'external' || r.origin === 'opaque');
return { rules: [...bulwark, rule, ...external] };
});
},
updateRule: (ruleId, updates) => {
set((state) => ({
rules: state.rules.map((r) => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, ...updates };
}),
}));
},
deleteRule: (ruleId) => {
set((state) => ({
rules: state.rules.filter((r) => {
if (r.id !== ruleId) return true;
return r.origin === 'external' || r.origin === 'opaque';
}),
}));
},
reorderRules: (ruleIds) => {
// Only reorder bulwark rules; external rules always stay at the end in
// their original order.
set((state) => {
const bulwarkMap = new Map(
state.rules.filter((r) => !r.origin || r.origin === 'bulwark').map((r) => [r.id, r]),
);
const external = state.rules.filter((r) => r.origin === 'external' || r.origin === 'opaque');
const reordered = ruleIds.map((id) => bulwarkMap.get(id)).filter(Boolean) as FilterRule[];
return { rules: [...reordered, ...external] };
});
},
toggleRule: (ruleId) => {
set((state) => ({
rules: state.rules.map((r) => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, enabled: !r.enabled };
}),
}));
},
setRawScript: (content) => set({ rawScript: content }),
// Switch to raw-script mode in one update so the visual rules are dropped
// atomically with the new content (mirrors the webmail save-sieve flow).
setOpaqueScript: (content) => set({ isOpaque: true, rawScript: content, rules: [] }),
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
clearState: () => set({
rules: [],
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
sieveCapabilities: null,
activeScriptId: null,
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
}),
}));
+44
View File
@@ -69,7 +69,14 @@ interface OfflineCacheState {
has: (id: string) => boolean;
get: (id: string) => Promise<Email | null>;
put: (email: Email, approxBytes: number) => Promise<void>;
// Shallow-merge a change into a cached email (no-op when not cached). Used to
// keep the cached body consistent with an optimistic/queued mutation so a
// re-open while offline reflects the new keywords / mailboxIds.
patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>) => Promise<void>;
remove: (ids: string[]) => Promise<void>;
// Evict oldest-received entries until the cache fits within maxBytes. Keeps
// the persisted cache from growing without bound on a noisy account.
evictToFit: (maxBytes: number) => Promise<void>;
clearAll: () => Promise<void>;
// Returns cached emails whose `mailboxIds` include the given mailbox,
@@ -200,6 +207,21 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index);
},
patch: async (id, changes) => {
const accountId = get().activeAccountId;
if (!accountId || !get().index.entries[id]) return;
try {
const raw = await AsyncStorage.getItem(entryKey(accountId, id));
if (!raw) return;
const email = JSON.parse(raw) as Email;
const updated: Email = { ...email, ...changes };
if (get().activeAccountId !== accountId) return;
await AsyncStorage.setItem(entryKey(accountId, id), JSON.stringify(updated));
} catch (err) {
console.warn('[offline-cache] patch failed', id, err);
}
},
remove: async (ids) => {
if (ids.length === 0) return;
const accountId = get().activeAccountId;
@@ -215,6 +237,28 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index);
},
evictToFit: async (maxBytes) => {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
const accountId = get().activeAccountId;
if (!accountId) return;
let total = get().totalSize();
if (total <= maxBytes) return;
// Drop the oldest mail first (by receivedAt) — that's what users expect a
// bounded "recent mail" cache to shed when it overflows.
const sorted = Object.values(get().index.entries).sort((a, b) => {
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return at - bt;
});
const toRemove: string[] = [];
for (const entry of sorted) {
if (total <= maxBytes) break;
toRemove.push(entry.id);
total -= entry.size;
}
if (toRemove.length > 0) await get().remove(toRemove);
},
clearAll: async () => {
const accountId = get().activeAccountId;
if (!accountId) return;
+291
View File
@@ -0,0 +1,291 @@
// Persistent offline action queue ("outbox") for mail mutations.
//
// Every mutation the app performs on a message reduces to one of three
// idempotent primitives:
// - keywords: replace the full keyword map (read/unread, flag, pin, …)
// - mailboxes: replace the full mailboxIds map (move, archive, trash)
// - destroy: permanently delete the message
//
// Because each primitive assigns the *whole* target state rather than a delta,
// replaying it is safe regardless of the server's current state, so we can
// coalesce repeated edits to the same message (last-write-wins) and retry on a
// flaky connection without corrupting anything.
//
// When the device is online and nothing is already queued for a message, the
// op runs immediately (preserving today's behaviour, including surfacing real
// server errors to the caller). Otherwise it's persisted to AsyncStorage and
// replayed by flush() once connectivity returns. Storage is keyed per account,
// mirroring offline-cache-store, so queues never leak between logins.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { generateUUID } from '../lib/uuid';
import { isTransientNetworkError } from '../lib/network-error';
import { useNetworkStore } from './network-store';
import { jmapClient } from '../api/jmap-client';
import { setEmailKeywords, setEmailMailboxes, destroyEmails } from '../api/email';
const KEY_PREFIX = 'webmail:outbox:v1:';
// Give up on an op that the server keeps rejecting (a non-transient failure)
// after this many attempts so one poison entry can't wedge the whole queue.
const MAX_ATTEMPTS = 5;
function storageKey(accountId: string): string {
return `${KEY_PREFIX}${accountId}`;
}
export type OutboxOp =
| { kind: 'keywords'; emailId: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string };
export interface OutboxEntry {
id: string;
op: OutboxOp;
createdAt: number;
attempts: number;
lastError?: string;
}
interface OutboxState {
// Currently-loaded account bucket. null = detached (no reads/writes).
activeAccountId: string | null;
entries: OutboxEntry[];
hydrated: boolean;
flushing: boolean;
setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>;
enqueue: (op: OutboxOp) => void;
count: () => number;
pendingForEmail: (emailId: string) => OutboxEntry[];
flush: () => Promise<void>;
clear: () => Promise<void>;
}
function persist(accountId: string, entries: OutboxEntry[]): void {
void AsyncStorage.setItem(storageKey(accountId), JSON.stringify(entries)).catch((err) => {
console.warn('[outbox] persist failed', err);
});
}
async function load(accountId: string): Promise<OutboxEntry[]> {
try {
const raw = await AsyncStorage.getItem(storageKey(accountId));
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as OutboxEntry[];
}
} catch (err) {
console.warn('[outbox] hydrate failed', err);
}
return [];
}
async function runOp(op: OutboxOp): Promise<void> {
switch (op.kind) {
case 'keywords':
return setEmailKeywords(op.emailId, op.keywords);
case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds);
case 'destroy':
return destroyEmails([op.emailId]);
}
}
export const useOutboxStore = create<OutboxState>((set, get) => ({
activeAccountId: null,
entries: [],
hydrated: false,
flushing: false,
setAccount: async (accountId) => {
const state = get();
if (state.activeAccountId === accountId) {
if (!state.hydrated) await get().hydrate();
return;
}
set({ activeAccountId: accountId, entries: [], hydrated: false });
if (accountId) {
const entries = await load(accountId);
// Re-check in case another setAccount raced past us.
if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true });
} else {
set({ hydrated: true });
}
},
hydrate: async () => {
const state = get();
if (state.hydrated) return;
const accountId = state.activeAccountId;
if (!accountId) {
set({ hydrated: true });
return;
}
const entries = await load(accountId);
if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true });
},
enqueue: (op) => {
const accountId = get().activeAccountId;
if (!accountId) {
console.warn('[outbox] enqueue with no active account; dropping op', op.kind);
return;
}
let entries = [...get().entries];
const { emailId } = op;
if (op.kind === 'destroy') {
// Destroy is terminal — drop any pending edits for this message and
// append the destroy so it runs last.
entries = entries.filter((e) => e.op.emailId !== emailId);
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
} else {
// A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => e.op.emailId === emailId && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same kind for this message
// (full-state replace makes the latest one authoritative). Keep its
// position so creation order is preserved for replay.
const idx = entries.findIndex((e) => e.op.emailId === emailId && e.op.kind === op.kind);
if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined };
} else {
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
}
}
set({ entries });
persist(accountId, entries);
},
count: () => get().entries.length,
pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId),
flush: async () => {
if (get().flushing) return;
const accountId = get().activeAccountId;
if (!accountId) return;
if (get().entries.length === 0) return;
// Need a live client that's actually serving this account, and a network.
if (!jmapClient.isConnected) return;
if (!useNetworkStore.getState().online) return;
set({ flushing: true });
try {
// Process oldest-first so dependent moves replay in the order they were
// made. We snapshot the order but re-read the live list each iteration,
// since a concurrent enqueue may have coalesced/removed entries.
const ordered = [...get().entries].sort((a, b) => a.createdAt - b.createdAt);
for (const snapshot of ordered) {
if (get().activeAccountId !== accountId) break; // account switched
if (!useNetworkStore.getState().online) break; // went offline
if (!jmapClient.isConnected) break;
const entry = get().entries.find((e) => e.id === snapshot.id);
if (!entry) continue; // removed/coalesced
try {
await runOp(entry.op);
removeEntry(accountId, entry.id);
} catch (err) {
if (get().activeAccountId !== accountId) break;
if (isTransientNetworkError(err)) {
// Connectivity blip — stop and let the next flush retry from here.
recordError(accountId, entry.id, err, false);
break;
}
// Server rejected it. Count the attempt; drop once we've given up so
// one bad op can't block everything behind it.
const attempts = (entry.attempts ?? 0) + 1;
if (attempts >= MAX_ATTEMPTS) {
console.warn('[outbox] dropping op after repeated failures', entry.op.kind, err);
removeEntry(accountId, entry.id);
} else {
recordError(accountId, entry.id, err, true);
}
}
}
} finally {
set({ flushing: false });
}
},
clear: async () => {
const accountId = get().activeAccountId;
set({ entries: [] });
if (accountId) {
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
}
},
}));
// ── Internal helpers that mutate+persist the live list ──────────────────────
function removeEntry(accountId: string, id: string): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const entries = store.entries.filter((e) => e.id !== id);
useOutboxStore.setState({ entries });
persist(accountId, entries);
}
function recordError(accountId: string, id: string, err: unknown, incrementAttempt: boolean): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const message = err instanceof Error ? err.message : String(err);
const entries = store.entries.map((e) =>
e.id === id
? { ...e, attempts: incrementAttempt ? (e.attempts ?? 0) + 1 : e.attempts, lastError: message }
: e,
);
useOutboxStore.setState({ entries });
persist(accountId, entries);
}
// ── Public entry points used by the email-store mutations ───────────────────
export interface ApplyResult {
/** True when the op was deferred to the queue instead of running now. */
queued: boolean;
}
// Run a batch of ops now when we're online and nothing is already queued for
// any of the affected messages; otherwise queue them for later replay. An
// optional `onlineRun` lets callers keep a richer single-round-trip online
// path (e.g. batch move, or archive's year/month auto-foldering) while still
// degrading to the idempotent primitives offline.
export async function applyOrQueueBatch(
ops: OutboxOp[],
onlineRun?: () => Promise<void>,
): Promise<ApplyResult> {
if (ops.length === 0) return { queued: false };
const store = useOutboxStore.getState();
const online = useNetworkStore.getState().online && jmapClient.isConnected;
const hasQueued = ops.some((op) =>
store.entries.some((e) => e.op.emailId === op.emailId),
);
if (online && !hasQueued) {
try {
await (onlineRun ? onlineRun() : Promise.all(ops.map(runOp)).then(() => undefined));
return { queued: false };
} catch (err) {
// A real server/validation error should bubble up exactly like before.
// Only fall through to the queue when the failure is connectivity.
if (!isTransientNetworkError(err)) throw err;
}
}
for (const op of ops) store.enqueue(op);
if (online) void store.flush();
return { queued: true };
}
export function applyOrQueue(op: OutboxOp, onlineRun?: () => Promise<void>): Promise<ApplyResult> {
return applyOrQueueBatch([op], onlineRun);
}
+113 -1
View File
@@ -7,7 +7,7 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type MailLayout = 'split' | 'focus';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
@@ -21,16 +21,72 @@ export type SwipeAction =
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
// stays reachable.
export type QuickAction =
| 'reply'
| 'replyAll'
| 'forward'
| 'delete'
| 'archive'
| 'markUnread'
| 'star'
| 'move'
| 'spam'
| 'tag';
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
export const ALL_QUICK_ACTIONS: QuickAction[] = [
'reply',
'replyAll',
'forward',
'delete',
'archive',
'markUnread',
'star',
'move',
'spam',
'tag',
];
// The reader bottom bar always shows exactly three quick actions (between the
// prev/next nav buttons). Coerce any persisted value into three unique, valid
// ids, backfilling from the reply-family default when entries are missing.
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
const out: QuickAction[] = [];
if (Array.isArray(value)) {
for (const a of value) {
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
out.push(a as QuickAction);
}
}
}
for (const d of REPLY_QUICK_ACTIONS) {
if (out.length >= 3) break;
if (!out.includes(d)) out.push(d);
}
return out.slice(0, 3);
}
export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type FirstDayOfWeek = 0 | 1;
export type TimeFormat = '12h' | '24h';
// Email-list date rendering style. Mirrors the webmail `dateFormat` setting:
// smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date)
// relative — "1h ago", "2d ago"
// full — always the full locale date + time
export type DateFormat = 'smart' | 'relative' | 'full';
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
export type FilesFolderLayout = 'inline' | 'sidebar';
export type FilesViewMode = 'list' | 'grid';
export type FilesSortKey = 'name' | 'size' | 'modified';
export type FilesSortDir = 'asc' | 'desc';
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
// Filename transform for downloads/exports (mirrors webmail SpaceReplacement).
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
const STORAGE_KEY = 'webmail:settings:v1';
@@ -51,6 +107,14 @@ interface PersistedSettings {
senderFavicons: boolean;
hideInlineImageAttachments: boolean;
// Language, region & time
dateFormat: DateFormat;
timeFormat: TimeFormat;
// Unified inbox: also pull in group/shared inboxes reachable through each
// logged-in account (parity with the webmail `includeGroupInUnified` setting).
includeGroupInUnified: boolean;
// Contacts
groupContactsByLetter: boolean;
@@ -88,6 +152,10 @@ interface PersistedSettings {
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
// family; reply-family actions removed from here move to the top toolbar.
bottomQuickActions: QuickAction[];
// Archive
archiveMode: ArchiveMode;
@@ -101,6 +169,10 @@ interface PersistedSettings {
showBirthdayCalendar: boolean;
enableCalendarTasks: boolean;
showTasksOnCalendar: boolean;
// Per-viewer color overrides for shared calendars, keyed by
// sharedCalendarColorKey(). Lets the user recolor calendars shared with
// them without changing the owner's color (parity with webmail #345).
sharedCalendarColors: Record<string, string>;
// Files
filesFolderLayout: FilesFolderLayout;
@@ -135,15 +207,30 @@ interface PersistedSettings {
// Plugins (UI-level enable map)
pluginEnabled: Record<string, boolean>;
// Downloads / export filenames: templates and a filename transform applied
// when exporting a message as .eml or saving an attachment.
emailExportTemplate: string;
attachmentExportTemplate: string;
exportSpaceReplacement: SpaceReplacement;
exportLowercase: boolean;
exportStripDiacritics: boolean;
// Offline mail cache: download recent message bodies in the background so
// they can be opened without network. Days windows the lookback. Attachment
// caching is intentionally not implemented yet — bodies-only is much
// smaller and covers the "open recent mail offline" UX on its own.
offlineCacheEnabled: boolean;
offlineCacheDays: number;
// Hard cap on the on-disk body cache, in megabytes. When a sync pushes the
// cache past this, the oldest messages are evicted to fit.
offlineCacheMaxMB: number;
}
const DEFAULT_PERSISTED: PersistedSettings = {
dateFormat: 'smart',
timeFormat: '24h',
includeGroupInUnified: false,
externalContentPolicy: 'ask',
trustedSenders: [],
trustedSendersAddressBook: false,
@@ -180,6 +267,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
swipeRightAction: 'read',
swipeMode: 'instant',
bottomQuickActions: ['reply', 'replyAll', 'forward'],
archiveMode: 'single',
calendarDefaultView: 'month',
@@ -191,6 +280,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
showBirthdayCalendar: true,
enableCalendarTasks: false,
showTasksOnCalendar: true,
sharedCalendarColors: {},
filesFolderLayout: 'inline',
filesDefaultViewMode: 'list',
@@ -219,8 +309,15 @@ const DEFAULT_PERSISTED: PersistedSettings = {
pluginEnabled: {},
emailExportTemplate: '{date} ({from}-{to}) {subject}',
attachmentExportTemplate: '{filename}',
exportSpaceReplacement: 'keep',
exportLowercase: false,
exportStripDiacritics: false,
offlineCacheEnabled: false,
offlineCacheDays: 7,
offlineCacheMaxMB: 50,
};
export interface SettingsState extends PersistedSettings {
@@ -261,6 +358,10 @@ export interface SettingsState extends PersistedSettings {
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
// Shared-calendar color overrides
setSharedCalendarColor: (key: string, color: string) => void;
removeSharedCalendarColor: (key: string) => void;
// Sidebar apps
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
@@ -383,6 +484,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
return get().trustedSenders.includes(normalized);
},
setSharedCalendarColor: (key, color) => {
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
persist(snapshot(get()));
},
removeSharedCalendarColor: (key) => {
const { [key]: _removed, ...rest } = get().sharedCalendarColors;
set({ sharedCalendarColors: rest });
persist(snapshot(get()));
},
addSidebarApp: (app) => {
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });