Compare commits

..
57 Commits
Author SHA1 Message Date
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
Linus Rath d16f04e818 chore: update version number to 0.1.39 2026-05-29 15:34:50 +02:00
Linus Rath d616943c25 feat: add unified inbox, scheduled send, iCal import/subscriptions, tasks, and contact tagging/import 2026-05-29 15:34:39 +02:00
Linus Rath cf4e3955bf chore: update version number to 0.1.38 2026-05-19 00:45:52 +02:00
Linus Rath bf20c0af58 feat: implement OAuth token handling and webmail-mediated login flow 2026-05-19 00:45:42 +02:00
Linus Rath 67f7f83621 chore: update version number to 0.1.37 2026-05-19 00:08:27 +02:00
Linus Rath 086d398058 feat: implement webmail-mediated login flow and remove OAuth dependencies 2026-05-19 00:05:51 +02:00
Linus Rath 373a1a60d8 chore: update version number to 0.1.36 2026-05-18 23:32:59 +02:00
Linus Rath f71f219187 feat: implement OAuth login flow and enhance authentication handling 2026-05-18 23:32:50 +02:00
Linus Rath 77b7be03d8 chore: update version number to 0.1.35 2026-05-18 21:52:49 +02:00
Linus Rath 9dd9ce8105 feat: enhance email store and offline cache to support account switching and data isolation 2026-05-18 21:52:35 +02:00
Linus Rath dd64931be8 chore: update version number to 0.1.34 2026-05-18 20:11:20 +02:00
Linus Rath ac234f288e Refactor email store to support incremental sync 2026-05-18 20:05:03 +02:00
Linus Rath 81749dd89c chore: update version number to 0.1.33 2026-05-15 17:26:10 +02:00
Linus Rath beef086d4c chore: update version number to 0.1.32 2026-05-15 17:26:00 +02:00
Linus Rath cfd67398de feat: add capability hooks for calendar, contacts, and files 2026-05-15 17:25:55 +02:00
Linus Rath 2b15d2dc50 chore: update version number to 0.1.31 2026-05-15 02:10:55 +02:00
Linus Rath 9bf2ecdf27 fix: resolve "Not authenticated" race on cold start and account switch 2026-05-15 00:31:57 +02:00
Linus Rath 67db8a2ac3 chore: update version number to 0.1.30 2026-05-14 13:51:00 +02:00
Linus Rath 6846d859fd feat: implement multi-account support for push notifications and enhance teardown logic 2026-05-14 13:50:52 +02:00
Linus Rath 3bdca0133e chore: update version number to 0.1.29 2026-05-14 12:57:18 +02:00
Linus Rath 1858d08dd0 feat: enhance notification handling with accountId support and improve file management functions 2026-05-14 12:57:03 +02:00
Linus Rath 427d163dc0 chore: update version number to 0.1.28 2026-05-08 20:04:32 +02:00
Linus Rath 7e34ba42ef feat: implement in-memory upload with uploadBytes function and integrate into createFolder 2026-05-08 20:04:25 +02:00
Linus Rath 886a64171d chore: update version number to 0.1.27 2026-05-08 13:18:24 +02:00
Linus Rath 760bf01f4d fix: defer expo-document-picker require to avoid native-module crash 2026-05-08 13:18:17 +02:00
Linus Rath b6d87093b9 chore: update version number to 0.1.26 2026-05-08 10:24:43 +02:00
Linus Rath 2a3b1f90dc feat: add upload, preview, download, selection, folder creation 2026-05-08 10:24:36 +02:00
Linus Rath 2ed9c29bd5 fix: clear status bar inset on OfflineCacheBanner 2026-05-08 01:36:49 +02:00
Linus Rath 455f26e90a chore: update version number to 0.1.25 2026-05-08 01:03:20 +02:00
Linus Rath e9b93bc6e5 fix: import ByteArrayOutputStream in BulwarkFcmModule 2026-05-08 01:03:11 +02:00
Linus Rath c21661ac7a feat: finish Files tab with folder nav, grid view, and refresh 2026-05-08 00:57:51 +02:00
Linus Rath 00be851611 chore: update version number to 0.1.24 2026-05-08 00:51:23 +02:00
Linus Rath 71ff8930a2 fix: apply unread/starred filters in mailbox queries 2026-05-08 00:50:27 +02:00
Linus Rath 29b06a3a4f security: fix push SSE breakage, harden APK updates and email rendering 2026-05-05 22:06:07 +02:00
Linus Rath 2911b8e7ac chore: update version number to 0.1.23 2026-05-03 23:23:15 +02:00
Linus Rath 3d4779a689 feat: enhance offline email handling with caching and mailbox seeding 2026-05-03 19:05:30 +02:00
125 changed files with 21515 additions and 1598 deletions
+72 -10
View File
@@ -15,7 +15,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -31,11 +31,14 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
@@ -43,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';
@@ -51,8 +55,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -75,6 +92,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
@@ -136,6 +157,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={CalendarScreen}
options={{
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -143,6 +171,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={ContactsScreen}
options={{
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -150,6 +185,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={FilesScreen}
options={{
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -227,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();
@@ -235,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
@@ -261,11 +315,11 @@ export default function App() {
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
await navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
void navigateToNotificationTap(payload);
});
return () => {
@@ -276,18 +330,24 @@ export default function App() {
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down the active subscription so the
// FirebaseMessagingService stops receiving pings for this device.
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId).catch(
() => undefined,
);
}
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
@@ -315,7 +375,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated, emailNotificationsEnabled]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
@@ -404,6 +464,8 @@ export default function App() {
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen
name="AddAccount"
options={{
+1 -1
View File
@@ -1 +1 @@
0.1.22
0.1.49
+9 -4
View File
@@ -1,12 +1,11 @@
<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"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -23,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>
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.google.firebase.messaging.FirebaseMessaging
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId,
)
promise.resolve(null)
}
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
conn.readTimeout = 3000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "bulwark-mobile")
conn.inputStream.use { BitmapFactory.decodeStream(it) }
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
// server can't OOM the headless task with a multi-megabyte image.
// We also sample down to ~256x256 since it'll only render at 192px.
val stream = conn.inputStream
val bytes = stream.use { input ->
val limit = MAX_FAVICON_BYTES
val buf = ByteArrayOutputStream()
val chunk = ByteArray(8 * 1024)
var total = 0
while (true) {
val n = input.read(chunk)
if (n <= 0) break
total += n
if (total > limit) return@use null
buf.write(chunk, 0, n)
}
buf.toByteArray()
} ?: return null
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
sample *= 2
}
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sample
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
} catch (_: Exception) {
null
}
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
fun removeListeners(count: Int) {}
companion object {
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
@Volatile private var currentInstance: BulwarkFcmModule? = null
fun emit(eventName: String, params: WritableMap?) {
@@ -17,17 +17,36 @@ object NotificationTapStore {
return value
}
// The activity is exported (required by the LAUNCHER intent filter), so
// any third-party app on the device can craft a starting intent that
// includes these extras. Reject anything that doesn't look like the IDs
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
val subject = extras.getString(EXTRA_SUBJECT)
val payload = TapPayload(emailId, threadId, subject)
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
// Subject is human-readable text and may legitimately contain anything;
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -35,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
+13 -1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
@@ -45,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",
+144 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.49",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,8 @@
"@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",
"expo-intent-launcher": "~13.0.8",
@@ -24,6 +26,8 @@
"expo-secure-store": "~15.0.8",
"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",
@@ -4755,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",
@@ -5202,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",
@@ -5216,6 +5246,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -5408,6 +5447,16 @@
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -6181,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",
@@ -6297,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",
@@ -6798,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",
@@ -6825,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",
@@ -7985,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",
@@ -8196,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",
@@ -8564,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",
@@ -8968,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",
@@ -9205,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",
@@ -9741,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",
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.49",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -10,6 +10,7 @@
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
@@ -22,6 +23,8 @@
"@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",
"expo-intent-launcher": "~13.0.8",
@@ -29,6 +32,8 @@
"expo-secure-store": "~15.0.8",
"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",
+33 -6
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should query events within date range', async () => {
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']],
});
@@ -50,12 +54,35 @@ describe('calendar operations', () => {
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
inCalendars: ['cal-1'],
after: '2026-03-01T00:00:00Z',
before: '2026-03-31T23:59:59Z',
types: ['Event'],
expect(call[0]).toBe('CalendarEvent/query');
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();
});
});
+60 -2
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -15,6 +16,9 @@ import {
createContact,
updateContact,
deleteContacts,
createAddressBook,
updateAddressBook,
deleteAddressBook,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -50,14 +54,14 @@ describe('contacts operations', () => {
expect(result).toEqual(['c1', 'c2']);
});
it('should use default empty filter', async () => {
it('should omit filter when none provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
});
await queryContacts();
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({});
expect(call[1].filter).toBeUndefined();
});
});
@@ -116,4 +120,58 @@ describe('contacts operations', () => {
expect(call[1].destroy).toEqual(['c1', 'c2']);
});
});
describe('createAddressBook', () => {
it('creates a book and returns the created entity', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
});
const book = await createAddressBook('Work');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
});
it('throws when the server refuses to create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
});
await expect(createAddressBook('X')).rejects.toThrow('nope');
});
});
describe('updateAddressBook', () => {
it('forwards only settable properties', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
});
});
describe('deleteAddressBook', () => {
it('destroys the book by id', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
});
await deleteAddressBook('ab-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ab-1']);
});
it('throws when destroy fails', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
});
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
});
});
});
+20 -2
View File
@@ -90,6 +90,24 @@ describe('email operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
});
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
});
const userFilter = {
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
};
await queryEmails('mb-1', { filter: userFilter });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, userFilter],
});
});
});
describe('getEmails', () => {
@@ -197,7 +215,7 @@ describe('email operations', () => {
expect(result).toEqual(['e1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'test query' });
expect(call[1].filter).toEqual({ text: 'test* query*' });
});
it('should include mailbox filter if specified', async () => {
@@ -208,7 +226,7 @@ describe('email operations', () => {
await searchEmails('query', 'mb-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
});
});
+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);
}
+55 -1
View File
@@ -61,16 +61,70 @@ export async function uploadBlob(
throw new Error('Upload succeeded but response did not include a blobId');
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
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'));
+243 -21
View File
@@ -4,12 +4,48 @@ 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',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'recurrenceRules',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
'recurrenceOverrides', 'excludedRecurrenceRules',
'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated',
];
@@ -23,43 +59,81 @@ 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 ?? [];
return list.map((cal) => ({ ...cal, 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,
);
@@ -69,38 +143,186 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
return all;
}
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
// / reply / cancellation emails to participants. We pass it whenever an event
// has participants so creating/updating/deleting a meeting notifies attendees.
function setArgs(
accountId: string,
payload: Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Record<string, unknown> {
const args: Record<string, unknown> = { accountId, ...payload };
if (sendSchedulingMessages !== undefined) {
args.sendSchedulingMessages = sendSchedulingMessages;
}
return args;
}
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', {
accountId,
[['CalendarEvent/set', setArgs(accountId, {
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
}, '0']],
}, sendSchedulingMessages), '0']],
USING,
);
return res.methodResponses[0][1].created['new-event'];
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
}
return created;
}
// Batch-create many events in one CalendarEvent/set. Returns the number created.
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
USING,
);
const result = methodResult<{ created?: Record<string, unknown> }>(res);
return result.created ? Object.keys(result.created).length : 0;
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
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,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update event');
}
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
);
}
export async function deleteEvents(ids: string[]): Promise<void> {
// RSVP to an invitation: patch the participant's participationStatus via a JSON
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
export async function rsvpEvent(
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
targetAccountId?: string,
): Promise<void> {
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> = {
[`participants/${escaped}/participationStatus`]: status,
};
if (replyTo) patch.replyTo = replyTo;
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
USING,
);
const result = methodResult<{
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
notParsable?: string[];
notFound?: string[];
}>(res);
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
return Array.isArray(parsed) ? parsed : [parsed];
}
export async function createCalendar(
name: string,
color?: string,
): Promise<Calendar> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
}, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-cal'];
if (!created) {
const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar');
}
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> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING,
);
}
+55
View File
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
USING,
);
}
export async function createAddressBook(name: string): Promise<AddressBook> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-book'];
if (created) return { ...created, name };
const err = result.notCreated?.['new-book'];
throw new Error(err?.description || err?.type || 'Failed to create address book');
}
export async function updateAddressBook(
id: string,
updates: Partial<AddressBook>,
): Promise<void> {
const accountId = jmapClient.accountId;
// Only forward server-settable properties.
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
}
export async function deleteAddressBook(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
}
/** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId });
return getContacts(ids);
}
+433 -9
View File
@@ -22,6 +22,55 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export interface MailboxChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
@@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise<void> {
}
}
function buildMailboxQueryFilter(
mailboxId: string,
userFilter: Record<string, unknown> | undefined,
): Record<string, unknown> {
const inMailbox = { inMailbox: mailboxId };
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
if ('operator' in userFilter) {
return { operator: 'AND', conditions: [inMailbox, userFilter] };
}
return { ...inMailbox, ...userFilter };
}
export async function queryEmails(
mailboxId: string,
options?: {
@@ -91,21 +156,69 @@ export async function queryEmails(
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
},
): Promise<{ ids: string[]; total: number }> {
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const accountId = jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
accountId,
filter: { inMailbox: mailboxId, ...options?.filter },
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
position: options?.position ?? 0,
limit: options?.limit ?? 50,
calculateTotal: true,
}, '0'],
]);
const body = res.methodResponses[0][1];
return {
ids: res.methodResponses[0][1].ids,
total: res.methodResponses[0][1].total,
ids: body.ids,
total: body.total,
queryState: body.queryState as string | undefined,
};
}
export interface EmailQueryChangesResult {
oldQueryState: string;
newQueryState: string;
total: number;
removed: string[];
added: Array<{ id: string; index: number }>;
}
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
// query. Returns null when the server replies with `cannotCalculateChanges`
// (or any other error) — caller should fall back to a fresh Email/query.
export async function getEmailQueryChanges(
mailboxId: string,
sinceQueryState: string,
options?: {
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
sinceQueryState,
calculateTotal: true,
};
if (options?.upToId) args.upToId = options.upToId;
if (options?.maxChanges) args.maxChanges = options.maxChanges;
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldQueryState: body.oldQueryState as string,
newQueryState: body.newQueryState as string,
total: (body.total as number) ?? 0,
removed: (body.removed as string[]) ?? [],
added: (body.added as Array<{ id: string; index: number }>) ?? [],
};
}
@@ -117,8 +230,63 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
return res.methodResponses[0][1].list;
}
export async function getFullEmail(id: string): Promise<Email> {
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list.
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: [], state: body.state as string };
}
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: body.list as Email[], state: body.state as string };
}
export interface EmailChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server replies with `cannotCalculateChanges` or any
// other error response; caller should treat that as "rebuild from scratch".
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
): Promise<EmailChangesResult | null> {
const accountId = jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
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,
@@ -155,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(
@@ -166,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'],
]);
@@ -192,6 +390,50 @@ export async function moveEmail(
]);
}
// Move several emails from one mailbox to another in a single Email/set.
export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
[`mailboxIds/${fromMailboxId}`]: null,
[`mailboxIds/${toMailboxId}`]: true,
};
}
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Batch delete: destroy outright when already in trash, otherwise move to trash.
export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId);
}
}
// Apply keyword maps to several emails in one round-trip.
export async function setKeywordsForEmails(
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords };
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
@@ -213,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.
@@ -392,6 +653,15 @@ export interface OutgoingAttachment {
cid?: string;
}
export interface SendEmailResult {
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
scheduled: boolean;
/** ISO timestamp the server resolved for a deferred send, when known. */
sendAt?: string;
emailId?: string;
emailSubmissionId?: string;
}
export async function sendEmail(
email: {
from: EmailAddress[];
@@ -407,7 +677,11 @@ export async function sendEmail(
},
identityId: string,
sentMailboxId: string,
): Promise<void> {
// When > 0 the message is held for this many seconds before delivery via the
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
// scheduling and the global send-delay (undo-send) window.
holdForSeconds?: number,
): Promise<SendEmailResult> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
from: email.from,
@@ -449,14 +723,164 @@ export async function sendEmail(
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
}
await jmapClient.request(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter).
if (holdForSeconds && holdForSeconds > 0) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim())
.filter(Boolean)
.map((address) => ({ email: address }));
submissionCreate.envelope = {
mailFrom: {
email: email.from[0]?.email,
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
},
rcptTo,
};
}
const res = await jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
create: { 'sub-1': submissionCreate },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let sendAt: string | undefined;
for (const [methodName, result] of res.methodResponses) {
if (methodName.endsWith('/error')) {
throw new Error((result as { description?: string }).description ?? 'Send failed');
}
if (methodName === 'Email/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.draft;
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
}
if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
emailSubmissionId = created?.id;
sendAt = created?.sendAt;
}
}
return {
scheduled: !!(holdForSeconds && holdForSeconds > 0),
sendAt,
emailId,
emailSubmissionId,
};
}
export interface ScheduledEmail {
emailSubmissionId: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt: string;
undoStatus?: string;
subject?: string;
to?: EmailAddress[];
from?: EmailAddress[];
preview?: string;
}
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
// send time is still in the future, joined with a light Email/get so the UI
// can show subject/recipients. Empty when the server lacks FUTURERELEASE.
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
if (!jmapClient.hasDelayedSend()) return [];
const accountId = jmapClient.accountId;
const now = Date.now();
const queryRes = await jmapClient.request(
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const [queryName, queryBody] = queryRes.methodResponses[0];
if (queryName.endsWith('/error')) return [];
const ids = (queryBody.ids as string[]) ?? [];
if (ids.length === 0) return [];
const subRes = await jmapClient.request(
[['EmailSubmission/get', {
accountId,
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const submissions = ((subRes.methodResponses[0][1].list as Array<{
id: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt?: string;
undoStatus?: string;
}>) ?? []).filter((s) => {
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
const t = new Date(s.sendAt).getTime();
return Number.isFinite(t) && t > now;
});
if (submissions.length === 0) return [];
const emailIds = Array.from(new Set(submissions.map((s) => s.emailId)));
const emailRes = await jmapClient.request([
['Email/get', {
accountId,
ids: emailIds,
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
}, '0'],
]);
const emailById = new Map(
((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]),
);
return submissions
.map((s): ScheduledEmail | null => {
const e = emailById.get(s.emailId);
if (!s.sendAt) return null;
return {
emailSubmissionId: s.id,
emailId: s.emailId,
identityId: s.identityId,
threadId: s.threadId ?? e?.threadId,
sendAt: s.sendAt,
undoStatus: s.undoStatus,
subject: e?.subject,
to: e?.to,
from: e?.from,
preview: e?.preview,
};
})
.filter((s): s is ScheduledEmail => s !== null)
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
}
// Cancel a pending scheduled send. The held message copy stays in Sent; only
// delivery is stopped (matches the webmail behaviour).
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['EmailSubmission/set', {
accountId,
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
}
}
+266 -28
View File
@@ -1,39 +1,277 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import type { FileNode, FileNodeRights, Principal } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
USING,
);
return res.methodResponses[0][1].list;
// 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;
}
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,
// 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',
];
export function supportsSharing(): boolean {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
}
function filesAccountId(): string {
return (
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
jmapClient.accountId
);
return res.methodResponses[0][1].created['new-file'];
}
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;
}
// 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 = filesAccountId();
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') {
throw new Error(result?.[1]?.description || 'FileNode list failed');
}
return (result[1].list ?? []) as FileNode[];
}
// 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 all;
}
export async function createFolder(
name: string,
parentId: string | null,
): Promise<FileNode> {
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 = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
fileUsing(),
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
export async function renameFileNode(id: string, newName: string): Promise<void> {
await updateFileNode(id, { name: newName });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
if (ids.length === 0) return;
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(),
);
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)`,
);
}
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
}
export async function uploadFileNode(
uri: string,
name: string,
mimeType: string,
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const blob = await uploadBlob(uri, mimeType);
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();
}
+212 -17
View File
@@ -8,6 +8,11 @@ import type {
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Refresh OAuth access tokens this many ms before they actually expire so
// in-flight requests don't race the expiry window.
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -22,6 +27,13 @@ export interface StoredCredentials {
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
clientId?: string;
}
export class JMAPClient {
@@ -48,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;
}
@@ -93,6 +112,113 @@ export class JMAPClient {
return this.session;
}
// OAuth login via webmail handoff. The webmail did the OAuth dance against
// Stalwart and handed us a complete token bundle. We persist the bundle
// so subsequent launches can refresh without prompting the user again.
async connectWithOAuth(
serverUrl: string,
tokens: OAuthTokens,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = {
serverUrl: baseUrl,
username: '',
password: '',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
// The JMAP session document carries the authenticated user's identifier
// — use it as the username so per-account storage keys are stable across
// restarts and OAuth re-logins.
const username = this.session.username || tokens.accessToken.slice(0, 8);
this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return { session: this.session, username, accountId };
}
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
// existing one when the response omits it.
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
const accountId = generateAccountId(
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
}
private currentOAuthTokens(): OAuthTokens | null {
if (
!this.credentials?.accessToken ||
!this.credentials.refreshToken ||
!this.credentials.tokenEndpoint ||
!this.credentials.clientId
) {
return null;
}
return {
accessToken: this.credentials.accessToken,
refreshToken: this.credentials.refreshToken,
expiresAt: this.credentials.expiresAt,
tokenEndpoint: this.credentials.tokenEndpoint,
clientId: this.credentials.clientId,
};
}
// Proactive refresh: when the access token is about to expire, swap it for
// a fresh one. Quiet no-op for password credentials.
private async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens();
if (!tokens) return;
if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and the reactive retry path catches it.
}
}
// Reactive refresh after a 401. Returns true if a fresh token was obtained
// so the caller can retry the original request.
private async forceRefreshToken(): Promise<boolean> {
const tokens = this.currentOAuthTokens();
if (!tokens) return false;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
return true;
} catch {
return false;
}
}
// Legacy single-slot restore - kept for backward-compat tests. New code
// should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> {
@@ -235,12 +361,18 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
@@ -280,14 +412,21 @@ export class JMAPClient {
methodCalls,
};
const response = await secureFetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(this.session!.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Session expired');
@@ -319,6 +458,59 @@ export class JMAPClient {
return core?.maxObjectsInGet || 500;
}
// ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
// lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail
// implementation so behaviour stays in sync across platforms.
private get submissionCapability():
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined {
return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined;
}
getMaxDelayedSend(): number {
const max = this.submissionCapability?.maxDelayedSend;
return typeof max === 'number' ? max : 0;
}
hasDelayedSend(): boolean {
const cap = this.submissionCapability;
if (!cap) return false;
const ext = cap.submissionExtensions;
// submissionExtensions is a map of extension name → params. FUTURERELEASE
// (RFC 4865) is the SMTP extension that backs deferred delivery.
const hasFutureRelease =
!!ext &&
typeof ext === 'object' &&
Object.keys(ext as Record<string, unknown>).some(
(k) => k.toUpperCase() === 'FUTURERELEASE',
);
return hasFutureRelease && this.getMaxDelayedSend() > 0;
}
// ── Stored credentials (per-account) ──────────────────
// Exposed so the unified-inbox aggregator can read another account's
// credentials without disturbing this client's live session, and persist a
// refreshed OAuth token back to secure storage.
async getStoredCredentials(accountId: string): Promise<StoredCredentials | null> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return null;
try {
return JSON.parse(stored) as StoredCredentials;
} catch {
return null;
}
}
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
@@ -335,9 +527,12 @@ export class JMAPClient {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await secureFetch(url, {
headers: { Authorization: this.authHeader },
});
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) throw new AuthenticationError('Session expired');
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer();
+27 -5
View File
@@ -201,13 +201,35 @@ export function startPolling(
return () => clearInterval(timer);
}
export interface StartPushOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
}
/**
* Start real-time updates, preferring SSE with polling fallback.
* Start real-time updates, preferring SSE with polling fallback. The `_client`
* argument is unused today (the underlying event source uses the singleton
* `jmapClient`) but is part of the call signature so the host App can pass
* the active client reference and trigger React re-runs on account switch.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
_client: unknown,
opts: StartPushOptions,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
const onError = opts.onError;
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
}
};
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
return () => undefined;
}
}
+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'] };
}
+131 -4
View File
@@ -9,12 +9,19 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
// RFC 8620 §2 — authenticated user identifier. Optional because not all
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
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];
@@ -284,6 +291,13 @@ export interface ContactCryptoKey {
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry' | string;
mediaType?: string;
pref?: number;
}
export interface ContactPronouns {
pronouns: string;
pref?: number;
@@ -318,12 +332,15 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
links?: Record<string, ContactLink>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: ContactSpeakToAs;
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -352,16 +369,43 @@ export interface AddressBookWithCount extends AddressBook {
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
'@type'?: 'Participant';
name?: string;
email?: string;
sendTo?: Record<string, string>;
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
calendarAddress?: string;
kind?: string;
roles?: Record<string, boolean>;
participationStatus?: string;
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
participationComment?: string;
scheduleStatus?: string[];
expectReply?: boolean;
description?: string;
}
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
export interface EventLocation {
'@type'?: 'Location';
name?: string;
description?: string;
locationTypes?: Record<string, boolean>;
relativeTo?: string;
timeZone?: string;
coordinates?: string;
}
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
export interface VirtualLocation {
'@type'?: 'VirtualLocation';
name?: string;
description?: string;
uri: string;
features?: Record<string, boolean>;
}
export interface RecurrenceRule {
'@type'?: 'RecurrenceRule';
frequency: string;
interval?: number;
until?: string;
@@ -376,6 +420,8 @@ export interface RecurrenceRule {
bySecond?: number[];
bySetPosition?: number[];
firstDayOfWeek?: string;
rscale?: string;
skip?: string;
}
export interface Alert {
@@ -399,27 +445,71 @@ export interface CalendarEvent {
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
replyTo?: Record<string, string>;
organizerCalendarAddress?: string;
sequence?: number;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
virtualLocations?: Record<string, VirtualLocation>;
links?: Record<string, { href: string; rel?: string }>;
created?: string;
updated?: string;
progress?: string;
// Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
due?: string;
priority?: number;
percentComplete?: number;
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;
}
export interface CalendarRights {
mayReadFreeBusy?: boolean;
mayReadItems?: boolean;
mayWriteAll?: boolean;
mayWriteOwn?: boolean;
mayUpdatePrivate?: boolean;
mayRSVP?: boolean;
mayShare?: boolean;
mayDelete?: boolean;
// Legacy short flag still read by the sidebar drawer.
mayWrite?: boolean;
}
export interface Calendar {
id: string;
name: string;
description?: string | null;
color?: string;
isVisible?: boolean;
isSubscribed?: boolean;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
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: 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) ───────────────────────────────────
@@ -429,10 +519,44 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
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 ─────────────────────────────────────────
@@ -465,4 +589,7 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
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;
+254
View File
@@ -0,0 +1,254 @@
import { CAPABILITIES } from './types';
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
import { jmapClient, type StoredCredentials } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Aggregated inbox across every logged-in account. Because the JMAP client is
// a single-account singleton (one live session at a time), we fetch each
// account's inbox "detached" here — reading its stored credentials directly and
// issuing requests against its own API URL — so the active account's live
// session is never disturbed.
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
];
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 {
emails: UnifiedEmail[];
/** accountId → error message for accounts that could not be fetched. */
errors: Record<string, string>;
}
function authHeaderFor(creds: StoredCredentials): string {
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
}
function originOf(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
// the origin we actually connected to (servers often self-report unreachable
// container-internal hosts).
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
const serverOrigin = originOf(serverUrl);
const apiOrigin = originOf(session.apiUrl);
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
}
function resolveJmapAccountId(session: JMAPSession): string | null {
return (
session.primaryAccounts?.[CAPABILITIES.MAIL] ||
session.primaryAccounts?.[CAPABILITIES.CORE] ||
Object.keys(session.accounts ?? {})[0] ||
null
);
}
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
async function ensureFreshCredentials(
accountId: string,
creds: StoredCredentials,
): Promise<StoredCredentials> {
if (
!creds.accessToken ||
!creds.refreshToken ||
!creds.tokenEndpoint ||
!creds.clientId ||
creds.expiresAt == null
) {
return creds;
}
if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds;
try {
const tokens: OAuthTokens = {
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
tokenEndpoint: creds.tokenEndpoint,
clientId: creds.clientId,
};
const next = await refreshOAuthAccessToken(tokens);
const updated: StoredCredentials = {
...creds,
accessToken: next.accessToken,
refreshToken: next.refreshToken ?? creds.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
await jmapClient.setStoredCredentials(accountId, updated);
return updated;
} catch {
// Fall back to the existing (possibly expired) token; the request will
// surface a 401 which we report as an error for this account.
return creds;
}
}
async function jmapPost(
apiUrl: string,
authHeader: string,
methodCalls: JMAPMethodCall[],
using: string[],
): Promise<Array<[string, Record<string, any>, string]>> {
const response = await secureFetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ using, methodCalls }),
});
if (response.status === 401) throw new Error('Session expired');
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
const body = await response.json();
return body.methodResponses ?? [];
}
// 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<Email[]> {
const mailboxResponses = await jmapPost(
apiUrl,
authHeader,
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
if (!inbox) return [];
const queryResponses = await jmapPost(
apiUrl,
authHeader,
[['Email/query', {
accountId: jmapAccountId,
filter: { inMailbox: inbox.id },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
if (ids.length === 0) return [];
const getResponses = await jmapPost(
apiUrl,
authHeader,
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
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, includeGroup);
} catch (err) {
errors[id] = err instanceof Error ? err.message : 'Failed to load';
return [] as UnifiedEmail[];
}
}),
);
const emails = settled
.flat()
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
return { emails, errors };
}
+48
View File
@@ -13,6 +13,45 @@ export interface LatestRelease {
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
// asset or from the release body. Null when the release didn't publish one;
// the installer falls back to relying solely on Android's package signing
// check in that case (still strong, but less defense-in-depth).
apkSha256: string | null;
}
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
function extractSha256(body: string, apkName: string | undefined): string | null {
if (!body) return null;
// Prefer a line that explicitly references the APK so we don't pick up the
// hash of an unrelated asset.
const lines = body.split(/\r?\n/);
if (apkName) {
for (const line of lines) {
if (line.includes(apkName)) {
const m = SHA256_RE.exec(line);
if (m) return m[1].toLowerCase();
}
}
}
// Fallback: a single SHA256 anywhere in the body — common when the release
// notes just list one checksum.
const m = SHA256_RE.exec(body);
return m ? m[1].toLowerCase() : null;
}
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
if (!res.ok) return null;
const text = await res.text();
const m = SHA256_RE.exec(text);
return m ? m[1].toLowerCase() : null;
} catch {
return null;
}
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
const sha256Asset = apkAsset
? json.assets.find(
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
)
: undefined;
const apkSha256 =
(await fetchCompanionSha256(sha256Asset)) ??
extractSha256(json.body || '', apkAsset?.name);
return {
tag: json.tag_name,
name: json.name || json.tag_name,
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
apkSha256,
};
}
+388 -8
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);
@@ -140,7 +467,18 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
const resolvedTheme = useResolvedTheme();
// Passively load the dedicated "Trusted Senders" address book once so its
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersLoaded, loadTrustedSendersBook]);
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
@@ -170,6 +508,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
@@ -261,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));
};
@@ -270,7 +637,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect, and persist to the
// dedicated "Trusted Senders" address book so the trust syncs across
// devices (matches the webmail behavior).
addTrustedSender(senderEmail);
void addToTrustedSendersBook(senderEmail).catch(() => {});
setAllowOnce(true);
}
};
@@ -297,19 +668,28 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={[styles.webContainer, { height }]}>
<WebView
ref={webviewRef}
originWhitelist={['about:*']}
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}
+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>
);
+12 -1
View File
@@ -3,8 +3,10 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -12,9 +14,18 @@ import { useColors } from '../theme/colors';
export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync);
// When UpdateBanner is stacked above us it already absorbs the status-bar
// inset, so we only add it when we're the topmost banner.
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const updateBannerVisible =
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
const topInset = updateBannerVisible ? 0 : insets.top;
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
const isError = sync.phase === 'error';
return (
<View style={[styles.banner, isError && styles.bannerError]}>
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
<CloudDownload size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>{title}</Text>
+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' },
});
}
+19 -7
View File
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
},
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, url); } catch (e) {}
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') === url) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : url;
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = url.replace(/"/g, '&quot;');
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) {}
}
@@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
<View style={[styles.container, { height }]}>
<WebView
ref={webViewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit ourselves.
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
return !url || url === 'about:blank' || url.startsWith('data:');
}}
/>
</View>
+30
View File
@@ -8,6 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -427,6 +428,24 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>All inboxes</Text>
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>Scheduled</Text>
</Pressable>
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
{foldersExpanded ? (
@@ -667,6 +686,17 @@ function makeStyles(c: ThemePalette) {
},
sectionSettings: { padding: 4 },
quickRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.md,
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
quickRowLabel: { ...typography.body, color: c.text },
empty: {
...typography.body,
color: c.textMuted,
+142
View File
@@ -0,0 +1,142 @@
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, Tag } from 'lucide-react-native';
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import type { Email } from '../api/types';
interface TagSheetProps {
visible: boolean;
onClose: () => void;
keywords: KeywordDef[];
/** The currently-selected emails, used to show which tags are already set. */
selectedEmails: Email[];
/** Toggle a keyword token on/off across the whole selection. */
onToggle: (token: string, on: boolean) => void;
}
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
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 });
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]);
// A tag counts as "applied" only when every selected email carries it; in
// that case tapping removes it, otherwise tapping adds it to all.
const allHaveToken = React.useCallback(
(token: string) =>
selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]),
[selectedEmails],
);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={dot} fill={dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
{applied && <Check size={16} color={c.primary} />}
</Pressable>
);
})
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
});
}
+202 -37
View File
@@ -11,12 +11,13 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X } 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;
@@ -24,6 +25,13 @@ interface CalendarSidebarDrawerProps {
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
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({
@@ -32,6 +40,11 @@ export function CalendarSidebarDrawer({
hiddenCalendarIds,
onToggle,
onClose,
onImport,
onManageSubscriptions,
onSetDefault,
onSetColor,
onResetColor,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -39,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) {
@@ -56,6 +70,7 @@ export function CalendarSidebarDrawer({
}),
]).start();
} else {
setExpandedId(null);
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
@@ -74,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
@@ -106,21 +136,38 @@ 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) && (
<View style={styles.actionsSection}>
{onManageSubscriptions && (
<Pressable
onPress={onManageSubscriptions}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Rss size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Subscriptions</Text>
</Pressable>
)}
{onImport && (
<Pressable
onPress={onImport}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Upload size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Import from file</Text>
</Pressable>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
@@ -134,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]);
@@ -147,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>
@@ -251,5 +363,58 @@ 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,
borderTopWidth: 1,
borderTopColor: c.border,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
minHeight: 44,
},
actionText: { ...typography.body, color: c.text },
});
}
+2 -1
View File
@@ -79,7 +79,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,
+145 -19
View File
@@ -3,6 +3,7 @@ import {
Animated,
Dimensions,
Easing,
Linking,
Modal,
Pressable,
ScrollView,
@@ -15,13 +16,16 @@ import {
AlignLeft,
Bell,
Calendar as CalendarIcon,
Check,
Clock,
Copy,
HelpCircle,
MapPin,
Pencil,
Repeat,
Trash2,
Users,
Video,
X,
} from 'lucide-react-native';
import { format } from 'date-fns';
@@ -33,8 +37,16 @@ import {
getEventColor,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
findParticipantByEmail,
isOrganizerParticipant,
} from '../../lib/calendar-invitation';
import { useAccountStore } from '../../stores/account-store';
import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
@@ -42,6 +54,7 @@ interface EventDetailSheetProps {
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
onDuplicate?: (event: CalendarEvent) => void;
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
@@ -84,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null {
return label;
}
function alertLabel(event: CalendarEvent): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first) return null;
const offset = first.trigger?.offset;
if (!offset) return null;
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
const min = offset.match(/-?PT(\d+)M$/);
if (min) return `${min[1]} minutes before`;
const hr = offset.match(/-?PT(\d+)H$/);
if (hr) return `${hr[1]} hours before`;
const day = offset.match(/-?P(\d+)D/);
if (day) return `${day[1]} days before`;
return null;
}
export function EventDetailSheet({
event,
calendars,
@@ -107,8 +104,11 @@ export function EventDetailSheet({
onEdit,
onDelete,
onDuplicate,
onRsvp,
}: EventDetailSheetProps) {
const c = useColors();
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -158,8 +158,26 @@ export function EventDetailSheet({
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const recurrence = recurrenceLabel(event);
const alert = alertLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
// Can the signed-in user RSVP? Only when they appear as a non-organizer
// participant and the caller wired an onRsvp handler.
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
const myStatus = me?.participant.participationStatus;
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !me || rsvpBusy) return;
setRsvpBusy(true);
try {
await onRsvp(event, me.id, status);
} finally {
setRsvpBusy(false);
}
};
return (
<Modal
@@ -198,6 +216,22 @@ export function EventDetailSheet({
showsVerticalScrollIndicator={false}
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
) : null}
{videoUri ? (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<Video size={16} color={c.textMuted} />
</View>
<Pressable
style={styles.joinBtn}
onPress={() => { void Linking.openURL(videoUri); }}
>
<Text style={styles.joinBtnText}>Join video call</Text>
</Pressable>
</View>
) : null}
{event.description ? (
<DetailRow
icon={<AlignLeft size={16} color={c.textMuted} />}
@@ -211,10 +245,10 @@ export function EventDetailSheet({
text={recurrence}
/>
)}
{alert && (
{reminders.length > 0 && (
<DetailRow
icon={<Bell size={16} color={c.textMuted} />}
text={alert}
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
/>
)}
{calendar && (
@@ -224,6 +258,38 @@ export function EventDetailSheet({
/>
)}
{canRsvp && (
<View style={styles.rsvpBlock}>
<Text style={styles.rsvpPrompt}>Going?</Text>
<View style={styles.rsvpButtons}>
<RsvpButton
label="Yes"
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
active={myStatus === 'accepted'}
activeColor={c.success}
disabled={rsvpBusy}
onPress={() => doRsvp('accepted')}
/>
<RsvpButton
label="Maybe"
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
active={myStatus === 'tentative'}
activeColor={c.warning}
disabled={rsvpBusy}
onPress={() => doRsvp('tentative')}
/>
<RsvpButton
label="No"
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
active={myStatus === 'declined'}
activeColor={c.error}
disabled={rsvpBusy}
onPress={() => doRsvp('declined')}
/>
</View>
</View>
)}
{participants.length > 0 && (
<View style={styles.participantsBlock}>
<View style={styles.participantsHeader}>
@@ -334,6 +400,39 @@ function ActionButton({
);
}
function RsvpButton({
label,
icon,
active,
activeColor,
disabled,
onPress,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function statusColor(c: ThemePalette, status?: string): string {
switch (status) {
case 'accepted':
@@ -417,6 +516,33 @@ function makeStyles(c: ThemePalette) {
detailIcon: { width: 16, paddingTop: 2 },
detailText: { flex: 1, ...typography.body, color: c.text },
joinBtn: {
flex: 1,
alignSelf: 'flex-start',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.primaryBg,
},
joinBtnText: { ...typography.bodyMedium, color: c.primary },
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
rsvpBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
rsvpBtnText: { ...typography.caption, color: c.text },
participantsBlock: {
marginTop: spacing.sm,
gap: spacing.xs,
+222 -87
View File
@@ -17,13 +17,23 @@ import {
Calendar as CalendarIcon,
Clock,
MapPin,
Video,
Repeat,
Bell,
Plus,
Users,
Trash2,
AlignLeft,
} from 'lucide-react-native';
import { addHours, addMinutes, format } from 'date-fns';
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
import type {
Calendar,
CalendarEvent,
Participant,
EventLocation,
RecurrenceRule,
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
@@ -33,11 +43,19 @@ import {
getEventStartDate,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import {
alertsToReminders,
remindersToAlerts,
formatReminder,
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 AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'none', label: 'Does not repeat' },
@@ -45,16 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
];
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
{ value: 'none', label: 'No reminder' },
{ value: 'at_time', label: 'At time of event' },
{ value: '5', label: '5 minutes before' },
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '1440', label: '1 day before' },
{ value: 'custom', label: 'Custom…' },
];
interface EventModalProps {
@@ -94,46 +103,41 @@ 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 detectAlert(event: CalendarEvent | null | undefined): AlertOption {
const alerts = event?.alerts;
if (!alerts) return 'none';
const first = Object.values(alerts)[0];
if (!first) return 'none';
const offset = first.trigger?.offset;
if (!offset) return 'none';
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
const min = offset.match(/-?PT(\d+)M$/);
if (min) {
const v = min[1];
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
}
const hr = offset.match(/-?PT(\d+)H$/);
if (hr && hr[1] === '1') return '60';
const day = offset.match(/-?P(\d+)D/);
if (day && day[1] === '1') return '1440';
return 'none';
function detectLocation(event: CalendarEvent | null | undefined): string {
const locations = event?.locations;
if (!locations) return '';
const first = Object.values(locations)[0];
return first?.name || '';
}
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
if (opt === 'none') return undefined;
let offset = '-PT0M';
if (opt === 'at_time') offset = 'PT0S';
else if (opt === '60') offset = '-PT1H';
else if (opt === '1440') offset = '-P1D';
else offset = `-PT${opt}M`;
return {
'a-1': {
trigger: { '@type': 'OffsetTrigger', offset },
action: 'display',
},
};
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
const v = event?.virtualLocations;
if (!v) return '';
const first = Object.values(v)[0];
return first?.uri || '';
}
function buildLocations(name: string): Record<string, EventLocation> | undefined {
const trimmed = name.trim();
if (!trimmed) return undefined;
return { 'loc-1': { '@type': 'Location', name: trimmed } };
}
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
const trimmed = uri.trim();
if (!trimmed) return undefined;
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
}
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
@@ -160,12 +164,23 @@ 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 [alert, setAlert] = React.useState<AlertOption>('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('');
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
const [alertOpen, setAlertOpen] = React.useState(false);
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
const [calendarOpen, setCalendarOpen] = React.useState(false);
const [showStartDate, setShowStartDate] = React.useState(false);
const [showStartTime, setShowStartTime] = React.useState(false);
@@ -183,8 +198,13 @@ export function EventModal({
setEnd(getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setRecurrence(detectRecurrence(event));
setAlert(detectAlert(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));
} else {
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
setTitle('');
@@ -192,10 +212,19 @@ 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');
setAlert('none');
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setLocation('');
setVideoUrl('');
}
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
@@ -239,8 +268,16 @@ 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),
alerts: alertFromOption(alert),
recurrenceRules:
recurrence === 'custom'
? customRule
? [customRule]
: undefined
: recurrenceFromOption(recurrence),
alerts: remindersToAlerts(reminders),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location),
virtualLocations: buildVirtualLocations(videoUrl),
};
await onSave(data, calendarId);
onClose();
@@ -254,8 +291,25 @@ export function EventModal({
};
const selectedCalendar = calendars.find((c) => c.id === calendarId);
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.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);
setReminders((prev) =>
prev.some((r) => r.minutesBefore === minutesBefore)
? prev
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
);
};
const removeReminder = (minutesBefore: number) => {
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
};
const availablePresets = REMINDER_PRESETS.filter(
(p) => !reminders.some((r) => r.minutesBefore === p),
);
return (
<Modal
@@ -412,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>
@@ -421,44 +481,99 @@ 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">
{reminders.length > 0 && (
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
<Pressable
onPress={() => removeReminder(r.minutesBefore)}
hitSlop={8}
style={styles.reminderRemove}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
</View>
)}
{availablePresets.length > 0 && (
<Pressable
style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
</Text>
</Pressable>
)}
{reminderPickerOpen && (
<View style={styles.popover}>
{availablePresets.map((preset) => (
<Pressable
key={preset}
style={styles.popoverRow}
onPress={() => addReminder(preset)}
>
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
</Pressable>
))}
</View>
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
<Pressable
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
<TextInput
value={location}
onChangeText={setLocation}
placeholder="Place or address"
placeholderTextColor={c.textMuted}
style={styles.fieldButton}
onPress={() => setAlertOpen((v) => !v)}
>
<Text style={styles.fieldText}>{alertLabel}</Text>
</Pressable>
{alertOpen && (
<View style={styles.popover}>
{ALERT_OPTIONS.map((opt) => (
<Pressable
key={opt.value}
style={[
styles.popoverRow,
opt.value === alert && styles.popoverRowActive,
]}
onPress={() => {
setAlert(opt.value);
setAlertOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
</Pressable>
))}
</View>
)}
/>
</Section>
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
<TextInput
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.fieldButton}
/>
</Section>
<Section
icon={<MapPin size={16} color={c.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
label="Description"
>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Notes, location, link…"
placeholder="Notes, agenda…"
placeholderTextColor={c.textMuted}
multiline
style={styles.descriptionInput}
@@ -629,6 +744,26 @@ function makeStyles(c: ThemePalette) {
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
reminderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
reminderRemove: { padding: 2 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
addRowText: { ...typography.body, color: c.primary },
popover: {
borderWidth: 1,
borderColor: c.border,
+391
View File
@@ -0,0 +1,391 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
import { parseCalendarBlob } from '../../api/calendar';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
interface Props {
visible: boolean;
onClose: () => void;
calendars: Calendar[];
/** Imports the selected events into the chosen calendar; returns count. */
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
onImported?: (count: number) => void;
}
function eventDateLabel(event: Partial<CalendarEvent>): string | null {
if (!event.start) return null;
const d = parseISO(event.start);
if (isNaN(d.getTime())) return null;
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm');
}
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const writable = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
[calendars],
);
const [parsed, setParsed] = React.useState<Partial<CalendarEvent>[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [calendarId, setCalendarId] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
setParsed([]);
setSelected(new Set());
setError(null);
setLoading(false);
setImporting(false);
setResult(null);
}, []);
React.useEffect(() => {
if (visible) {
resetState();
if (writable[0]) setCalendarId(writable[0].id);
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, slideY, overlayOpacity, resetState]);
const pickFile = async () => {
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
);
return;
}
setError(null);
let res;
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
return;
}
setLoading(true);
try {
// Upload the .ics so the server can parse it (CalendarEvent/parse).
const { blobId } = await uploadBlob(asset.uri, 'text/calendar');
const events = await parseCalendarBlob(blobId);
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
if (usable.length === 0) {
setError('No events were found in that file.');
return;
}
setParsed(usable);
setSelected(new Set(usable.map((_, i) => i)));
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
} finally {
setLoading(false);
}
};
const toggle = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
const selectNone = () => setSelected(new Set());
const doImport = async () => {
if (!calendarId) {
Alert.alert('No calendar', 'Create a calendar before importing events.');
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await onImport(toImport, calendarId);
setResult(count);
onImported?.(count);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
} finally {
setImporting(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import calendar</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} event{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
{loading ? (
<ActivityIndicator color={c.primary} />
) : (
<Upload size={32} color={c.textMuted} />
)}
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
{writable.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
{writable.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((event, idx) => {
const isSelected = selected.has(idx);
const date = eventDateLabel(event);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<Clock size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
</View>
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '85%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
dropZone: {
width: '100%',
borderWidth: 2,
borderColor: c.border,
borderStyle: 'dashed',
borderRadius: radius.lg,
paddingVertical: spacing.xxxl,
alignItems: 'center',
gap: spacing.sm,
},
dropTitle: { ...typography.bodyMedium, color: c.text },
dropHint: { ...typography.caption, color: c.textMuted },
successBadge: {
width: 56, height: 56, borderRadius: radius.full,
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
},
successText: { ...typography.bodyMedium, color: c.text },
calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm },
calChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
maxWidth: 160,
},
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
calSwatch: { width: 10, height: 10, borderRadius: 5 },
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
toolbarCount: { ...typography.caption, color: c.textMuted },
link: { ...typography.captionMedium, color: c.primary },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 48,
},
rowPressed: { backgroundColor: c.surfaceHover },
checkbox: {
width: 20, height: 20, borderRadius: radius.xs,
borderWidth: 1, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
},
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
rowName: { ...typography.body, color: c.text },
rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
@@ -0,0 +1,262 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
interface Props {
visible: boolean;
onClose: () => void;
}
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
const [name, setName] = React.useState('');
const [url, setUrl] = React.useState('');
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
const [adding, setAdding] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
setName('');
setUrl('');
setColor(CALENDAR_COLOR_PALETTE[0]);
setFormError(null);
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const handleAdd = async () => {
const n = name.trim();
const u = url.trim();
if (!n || !u) {
setFormError('Enter a name and a feed URL.');
return;
}
if (!/^(https?|webcal):\/\//i.test(u)) {
setFormError('URL must start with https://, http:// or webcal://');
return;
}
setFormError(null);
setAdding(true);
try {
const sub = await addSubscription({ name: n, url: u, color });
if (sub.lastError) {
setFormError(sub.lastError);
} else {
setName('');
setUrl('');
}
} catch (e) {
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
} finally {
setAdding(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
<Text style={styles.title}>Calendar subscriptions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
<Text style={styles.sectionLabel}>Add a feed</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Name (e.g. Holidays)"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://… or webcal://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.input}
/>
<View style={styles.colorRow}>
{CALENDAR_COLOR_PALETTE.map((col) => (
<Pressable
key={col}
onPress={() => setColor(col)}
style={[
styles.colorDot,
{ backgroundColor: col },
color === col && styles.colorDotActive,
]}
/>
))}
</View>
{!!formError && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{formError}</Text>
</View>
)}
<Button
onPress={() => { void handleAdd(); }}
disabled={adding}
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
>
{adding ? 'Subscribing…' : 'Subscribe'}
</Button>
{subscriptions.length > 0 && (
<>
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
{subscriptions.map((sub) => {
const busy = !!syncing[sub.id];
return (
<View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}>
{sub.lastError
? sub.lastError
: sub.lastSyncAt
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
: 'Not synced yet'}
</Text>
</View>
<Pressable
onPress={() => { void syncSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<RefreshCw size={16} color={c.textSecondary} />
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
);
})}
</>
)}
</ScrollView>
<View style={{ height: insets.bottom }} />
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '88%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { flex: 1, ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
scroll: { padding: spacing.lg, gap: spacing.sm },
sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs },
input: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
marginBottom: spacing.sm,
},
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
colorDotActive: { borderColor: c.text },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
marginBottom: spacing.sm,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
subRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
subSwatch: { width: 14, height: 14, borderRadius: 7 },
subName: { ...typography.body, color: c.text },
subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 },
subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
});
}
@@ -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 },
});
}
+371
View File
@@ -0,0 +1,371 @@
import React from 'react';
import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
import {
Calendar as CalendarIcon,
Check,
ListChecks,
Plus,
Trash2,
X,
} from 'lucide-react-native';
import { format, parseISO } 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 { getCalendarColor } from '../../lib/calendar-utils';
import { useSheetDrag } from '../../lib/use-sheet-drag';
interface TasksSheetProps {
visible: boolean;
tasks: CalendarEvent[];
calendars: Calendar[];
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onToggle: (id: string) => Promise<void> | void;
onDelete: (id: string) => Promise<void> | void;
}
function isCompleted(task: CalendarEvent): boolean {
return task.progress === 'completed';
}
function dueLabel(task: CalendarEvent): string | null {
if (!task.due) return null;
const d = parseISO(task.due);
if (isNaN(d.getTime())) return null;
return format(d, 'EEE, MMM d');
}
// Tasks first by completion (open first), then by due date (soonest first,
// undated last), then title.
function compareTasks(a: CalendarEvent, b: CalendarEvent): number {
const ac = isCompleted(a);
const bc = isCompleted(b);
if (ac !== bc) return ac ? 1 : -1;
const ad = a.due ? parseISO(a.due).getTime() : Infinity;
const bd = b.due ? parseISO(b.due).getTime() : Infinity;
if (ad !== bd) return ad - bd;
return (a.title || '').localeCompare(b.title || '');
}
export function TasksSheet({
visible,
tasks,
calendars,
onClose,
onCreate,
onToggle,
onDelete,
}: TasksSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
const writableCalendars = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
[calendars],
);
const [title, setTitle] = React.useState('');
const [calendarId, setCalendarId] = React.useState('');
const [due, setDue] = React.useState<Date | null>(null);
const [showDuePicker, setShowDuePicker] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (visible && !calendarId && writableCalendars[0]) {
setCalendarId(writableCalendars[0].id);
}
}, [visible, calendarId, writableCalendars]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]);
const handleAdd = async () => {
const t = title.trim();
if (!t || !calendarId || saving) return;
setSaving(true);
try {
const task: Partial<CalendarEvent> = {
title: t,
progress: 'needs-action',
};
if (due) {
task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss");
}
await onCreate(task, calendarId);
setTitle('');
setDue(null);
} finally {
setSaving(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>Tasks</Text>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
</View>
<View style={styles.addRow}>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Add a task…"
placeholderTextColor={c.textMuted}
style={styles.addInput}
returnKeyType="done"
onSubmitEditing={() => { void handleAdd(); }}
/>
<Pressable
onPress={() => setShowDuePicker(true)}
style={[styles.dueChip, due && styles.dueChipActive]}
hitSlop={6}
>
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
</Pressable>
<Pressable
onPress={() => { void handleAdd(); }}
disabled={!title.trim() || !calendarId || saving}
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
{writableCalendars.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.calChips}
>
{writableCalendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{sorted.length === 0 ? (
<View style={styles.empty}>
<ListChecks size={32} color={c.surfaceActive} />
<Text style={styles.emptyText}>No tasks yet</Text>
</View>
) : (
sorted.map((task) => {
const completed = isCompleted(task);
const due = dueLabel(task);
return (
<View key={task.id} style={styles.taskRow}>
<Pressable
onPress={() => { void onToggle(task.id); }}
style={[styles.checkbox, completed && styles.checkboxChecked]}
hitSlop={8}
>
{completed && <Check size={14} color={c.primaryForeground} />}
</Pressable>
<View style={styles.taskText}>
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || 'Untitled task'}
</Text>
{due && <Text style={styles.taskDue}>{due}</Text>}
</View>
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
<Trash2 size={16} color={c.textMuted} />
</Pressable>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
{showDuePicker && (
<DateTimePicker
value={due ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowDuePicker(false);
if (d) setDue(d);
}}
/>
)}
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '85%',
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerTitle: { flex: 1, ...typography.h3, color: c.text },
closeBtn: { padding: 4 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
},
addInput: {
flex: 1,
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
},
dueChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
height: 38,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
dueChipText: { ...typography.caption, color: c.primary },
addBtn: {
width: 38,
height: 38,
borderRadius: radius.sm,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
addBtnDisabled: { opacity: 0.5 },
calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm },
calChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
maxWidth: 160,
},
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
calSwatch: { width: 10, height: 10, borderRadius: 5 },
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
list: { flexGrow: 0 },
listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm },
emptyText: { ...typography.body, color: c.textMuted },
taskRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
checkbox: {
width: 22,
height: 22,
borderRadius: radius.xs,
borderWidth: 2,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary },
taskText: { flex: 1 },
taskTitle: { ...typography.body, color: c.text },
taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted },
taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 },
taskDelete: { padding: 4 },
});
}
@@ -0,0 +1,218 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BookUser, X, Check, Plus } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
interface Props {
visible: boolean;
onClose: () => void;
/** Book the contact(s) currently live in - shown with a check, not selectable. */
currentBookId?: string | null;
onPick: (addressBookId: string) => void;
title?: string;
}
export default function AddressBookPickerSheet({
visible, onClose, currentBookId, onPick, title = 'Move to address book',
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const addressBooks = useContactsStore((s) => s.addressBooks);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
const [creating, setCreating] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
React.useEffect(() => {
if (visible) {
setCreating(false);
setNewName('');
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: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const handleCreate = async () => {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
const book = await createAddressBook(name);
setCreating(false);
setNewName('');
onPick(book.id);
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{addressBooks.map((book) => {
const isCurrent = book.id === currentBookId;
const canWrite = book.myRights?.mayWrite !== false;
const canTarget = canWrite && !isCurrent;
return (
<Pressable
key={book.id}
onPress={canTarget ? () => onPick(book.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
>
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text style={[styles.rowLabel, !canTarget && styles.rowLabelDisabled]} numberOfLines={1}>
{book.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
{creating ? (
<View style={styles.createRow}>
<TextInput
style={styles.createInput}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
value={newName}
onChangeText={setNewName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void handleCreate(); }}
/>
<Pressable
onPress={() => { void handleCreate(); }}
disabled={!newName.trim() || busy}
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
hitSlop={6}
>
<Check size={16} color={c.primaryForeground} />
</Pressable>
</View>
) : (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Plus size={16} color={c.primary} />
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
</Pressable>
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowLabelDisabled: { color: c.textMuted },
createRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
createInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
createBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.primary,
borderRadius: radius.full,
},
createBtnDisabled: { opacity: 0.4 },
});
}
+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 },
@@ -0,0 +1,391 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
import type { ContactCard } from '../../api/types';
import { parseVCard, detectDuplicates } from '../../lib/vcard';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
import { useContactsStore } from '../../stores/contacts-store';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
interface Props {
visible: boolean;
onClose: () => void;
/** Address book the imported contacts are created in. */
targetBookId: string | null;
targetBookName?: string;
onChangeTarget?: () => void;
onImported?: (count: number) => void;
}
export default function ContactImportSheet({
visible, onClose, targetBookId, targetBookName, onChangeTarget, onImported,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const existing = useContactsStore((s) => s.contacts);
const importContacts = useContactsStore((s) => s.importContacts);
const [parsed, setParsed] = React.useState<ContactCard[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
const [error, setError] = React.useState<string | null>(null);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
setParsed([]);
setSelected(new Set());
setDuplicates(new Map());
setError(null);
setImporting(false);
setResult(null);
}, []);
React.useEffect(() => {
if (visible) {
resetState();
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity, resetState]);
const pickFile = async () => {
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
);
return;
}
setError(null);
let res;
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
return;
}
try {
const text = await FileSystem.readAsStringAsync(asset.uri);
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError('No contacts were found in that file.');
return;
}
const dupes = detectDuplicates(existing, contacts);
const initial = new Set<number>();
contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); });
setParsed(contacts);
setDuplicates(dupes);
setSelected(initial);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
}
};
const toggle = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
const selectNone = () => setSelected(new Set());
const doImport = async () => {
if (!targetBookId) {
Alert.alert('No address book', 'Create an address book before importing contacts.');
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await importContacts(toImport, targetBookId);
setResult(count);
onImported?.(count);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
} finally {
setImporting(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import contacts</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} contact{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
<Upload size={32} color={c.textMuted} />
<Text style={styles.dropTitle}>Choose a vCard file</Text>
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
<Pressable
style={styles.targetRow}
onPress={onChangeTarget}
disabled={!onChangeTarget}
>
<BookUser size={16} color={c.textSecondary} />
<Text style={styles.targetText} numberOfLines={1}>
Import into {targetBookName || 'address book'}
</Text>
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
</Pressable>
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}>
<Text style={styles.link}>All</Text>
</Pressable>
<Pressable onPress={selectNone} hitSlop={6}>
<Text style={styles.link}>None</Text>
</Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((contact, idx) => {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const isDupe = duplicates.has(idx);
const isSelected = selected.has(idx);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<FileText size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{name || email || '—'}</Text>
{!!email && !!name && (
<Text style={styles.rowEmail} numberOfLines={1}>{email}</Text>
)}
</View>
{isDupe && (
<Text style={styles.dupeBadge}>Duplicate</Text>
)}
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '85%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
dropZone: {
width: '100%',
borderWidth: 2,
borderColor: c.border,
borderStyle: 'dashed',
borderRadius: radius.lg,
paddingVertical: spacing.xxxl,
alignItems: 'center',
gap: spacing.sm,
},
dropTitle: { ...typography.bodyMedium, color: c.text },
dropHint: { ...typography.caption, color: c.textMuted },
successBadge: {
width: 56, height: 56, borderRadius: radius.full,
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
},
successText: { ...typography.bodyMedium, color: c.text },
targetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
targetText: { ...typography.caption, color: c.textSecondary, flex: 1 },
targetChange: { ...typography.captionMedium, color: c.primary },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
toolbarCount: { ...typography.caption, color: c.textMuted },
link: { ...typography.captionMedium, color: c.primary },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 48,
},
rowPressed: { backgroundColor: c.surfaceHover },
checkbox: {
width: 20, height: 20, borderRadius: radius.xs,
borderWidth: 1, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
},
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
rowName: { ...typography.body, color: c.text },
rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
dupeBadge: {
...typography.small,
color: c.warning,
backgroundColor: c.warningBg,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
overflow: 'hidden',
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
+195
View File
@@ -0,0 +1,195 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tag, X, Plus } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
interface Props {
visible: boolean;
onClose: () => void;
/** Called with the chosen tag (existing or freshly typed). */
onPick: (keyword: string) => void;
title?: string;
}
export default function TagAssignSheet({
visible, onClose, onPick, title = 'Add tag',
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// 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('');
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
React.useEffect(() => {
if (visible) {
setInput('');
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: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const submitNew = () => {
const kw = input.trim();
if (!kw) return;
onPick(kw);
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="New tag name"
placeholderTextColor={c.textMuted}
value={input}
onChangeText={setInput}
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={submitNew}
/>
<Pressable
onPress={submitNew}
disabled={!input.trim()}
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
hitSlop={6}
>
<Plus size={16} color={c.primaryForeground} />
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags yet. Type one above.</Text>
) : (
keywords.map((kw) => (
<Pressable
key={kw.keyword}
onPress={() => onPick(kw.keyword)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={c.textSecondary} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
<Text style={styles.rowCount}>{kw.count}</Text>
</Pressable>
))
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
addBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.primary,
borderRadius: radius.full,
},
addBtnDisabled: { opacity: 0.4 },
empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowCount: { ...typography.caption, color: c.textMuted },
});
}
+3
View File
@@ -1,4 +1,7 @@
export { default as AddressBookPickerSheet } from './AddressBookPickerSheet';
export { default as ContactImportSheet } from './ContactImportSheet';
export { default as ContactListRow } from './ContactListRow';
export { default as ContactPickerSheet } from './ContactPickerSheet';
export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer';
export { default as TagAssignSheet } from './TagAssignSheet';
export { default as FieldBlock, FieldRow } from './FieldBlock';
@@ -0,0 +1,305 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
} from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useAccountStore } from '../../stores/account-store';
import { useSettingsStore } from '../../stores/settings-store';
import {
findCalendarAttachment,
findParticipantByEmail,
getOrganizerName,
buildReplyTo,
inferInvitationMethod,
type InvitationMethod,
} from '../../lib/calendar-invitation';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
interface Props {
email: Email;
}
export function CalendarInvitationBanner({ email }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const calendars = useCalendarStore((s) => s.calendars);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
const [state, setState] = React.useState<BannerState>('loading');
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!attachment || !enabled) return;
setState('loading');
(async () => {
try {
const events = await parseCalendarBlob(attachment.blobId);
if (cancelled) return;
if (events.length === 0) {
setState('error');
return;
}
const parsed = events[0];
setEvent(parsed);
setMethod(inferInvitationMethod(parsed));
setState('parsed');
} catch {
if (!cancelled) setState('error');
}
})();
return () => { cancelled = true; };
}, [attachment, enabled]);
if (!attachment || !enabled || state === 'error') return null;
if (state === 'loading') {
return (
<View style={styles.banner}>
<ActivityIndicator size="small" color={c.primary} />
<Text style={styles.loadingText}>Reading invitation…</Text>
</View>
);
}
if (!event) return null;
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy')
: format(startDate, 'EEE, MMM d · HH:mm'))
: null;
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
// Make sure the event exists in a local calendar (dedupes by UID).
await importEvents([event], writableCalendar.id);
// Re-read from the store to get the server-assigned id + participant.
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
const participant = stored && activeEmail
? findParticipantByEmail(stored, [activeEmail])
: me;
if (stored && participant) {
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
setRsvpStatus(status);
setNotice('Response sent');
} else {
setNotice('Added to calendar');
}
setState('done');
} catch {
setNotice('Could not send your response');
} finally {
setBusy(false);
}
};
const handleImport = async () => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
const count = await importEvents([event], writableCalendar.id);
setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar');
setState('done');
} catch {
setNotice('Could not add the event');
} finally {
setBusy(false);
}
};
return (
<View style={styles.banner}>
<View style={styles.headerRow}>
<View style={styles.iconBadge}>
<CalendarDays size={18} color={c.primary} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
</View>
</View>
{dateLabel && (
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
)}
{location ? (
<Row icon={<MapPin size={15} color={c.textMuted} />} text={location} styles={styles} />
) : null}
{videoUri ? (
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
<Video size={15} color={c.textMuted} />
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
</Pressable>
) : null}
{organizer ? (
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
) : null}
{notice && <Text style={styles.notice}>{notice}</Text>}
{state !== 'done' && (
<View style={styles.actions}>
{canRsvp ? (
<>
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
</>
) : (
<Pressable
style={[styles.importBtn, busy && { opacity: 0.5 }]}
onPress={() => { void handleImport(); }}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.primaryForeground} />
) : (
<CalendarPlus size={16} color={c.primaryForeground} />
)}
<Text style={styles.importBtnText}>Add to calendar</Text>
</Pressable>
)}
</View>
)}
{!writableCalendar && state !== 'done' && (
<View style={styles.warnRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>No writable calendar available</Text>
</View>
)}
</View>
);
}
function Row({
icon, text, styles,
}: {
icon: React.ReactNode;
text: string;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.detailRow}>
{icon}
<Text style={styles.detailText} numberOfLines={2}>{text}</Text>
</View>
);
}
function RsvpBtn({
label, icon, active, activeColor, disabled, onPress, c, styles,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
gap: spacing.xs,
},
loadingText: { ...typography.caption, color: c.textMuted },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs },
iconBadge: {
width: 32, height: 32, borderRadius: radius.sm,
backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center',
},
title: { ...typography.bodySemibold, color: c.text },
cancelled: { ...typography.caption, color: c.error, marginTop: 2 },
detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 },
detailText: { flex: 1, ...typography.caption, color: c.textSecondary },
notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs },
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
rsvpBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
rsvpBtnText: { ...typography.caption, color: c.text },
importBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: c.primary,
},
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
warnText: { ...typography.caption, color: c.warning },
});
}
+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 },
});
}
+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 },
});
}
@@ -16,6 +16,8 @@ export function ComposingSettings() {
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -37,6 +39,14 @@ export function ComposingSettings() {
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
};
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
{ label: 'Off', value: 0 },
{ label: '5s', value: 5 },
{ label: '10s', value: 10 },
{ label: '20s', value: 20 },
{ label: '30s', value: 30 },
];
return (
<SettingsSection
title="Composing"
@@ -49,6 +59,29 @@ export function ComposingSettings() {
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Undo send delay</Text>
<Text style={styles.subDescription}>
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
</Text>
<View style={styles.segmentRow}>
{SEND_DELAY_OPTIONS.map((opt) => {
const active = sendDelaySeconds === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
@@ -110,6 +143,19 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
segmentActive: { backgroundColor: c.primary, borderColor: c.primary },
segmentText: { ...typography.caption, color: c.text },
segmentTextActive: { color: c.primaryForeground, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
+278 -35
View File
@@ -1,30 +1,57 @@
import React from 'react';
import { Alert, Share, Text } from 'react-native';
import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Download } from 'lucide-react-native';
import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet } from '../contacts';
import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function ContactsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
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);
const renameAddressBook = useContactsStore((s) => s.renameAddressBook);
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
const [exporting, setExporting] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editName, setEditName] = React.useState('');
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
exportable.length === 0
? 'No contacts to export'
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
const importTargetBookId = books[0]?.id ?? null;
const handleExport = async () => {
if (exportable.length === 0 || exporting) return;
@@ -50,40 +77,256 @@ export function ContactsSettings() {
}
};
const startRename = (id: string, name: string) => {
setAdding(false);
setEditingId(id);
setEditName(name);
};
const commitRename = async () => {
const name = editName.trim();
if (!editingId || !name || busy) { setEditingId(null); return; }
setBusy(true);
try {
await renameAddressBook(editingId, name);
setEditingId(null);
} catch (err) {
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const commitCreate = async () => {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
await createAddressBook(name);
setAdding(false);
setNewName('');
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
const { id } = deleteTarget;
setDeleteTarget(null);
try {
await deleteAddressBook(id);
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
}
};
return (
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
<>
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
>
Export
</Button>
</SettingItem>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
<SettingItem
label="Import contacts"
description="Import contacts from a vCard (.vcf) file with duplicate detection."
>
<Button
variant="outline"
size="sm"
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportOpen(true)}
disabled={books.length === 0}
>
Import
</Button>
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
>
Export
</Button>
</SettingItem>
</SettingsSection>
<SettingsSection
title="Address books"
description="Create, rename, and remove the address books that organize your contacts."
>
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
{books.map((book) => (
<View key={book.id} style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
{editingId === book.id ? (
<>
<TextInput
style={styles.bookInput}
value={editName}
onChangeText={setEditName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitRename(); }}
placeholderTextColor={c.textMuted}
/>
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</>
) : (
<>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.bookName} numberOfLines={1}>{book.name}</Text>
<Text style={styles.bookCount}>
{book.count} contact{book.count === 1 ? '' : 's'}
</Text>
</View>
{book.myRights?.mayWrite !== false && (
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
<Pencil size={15} color={c.textSecondary} />
</Pressable>
)}
{book.myRights?.mayDelete !== false && books.length > 1 && (
<Pressable
onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })}
hitSlop={6}
style={styles.iconBtn}
>
<Trash2 size={15} color={c.error} />
</Pressable>
)}
</>
)}
</View>
))}
{adding ? (
<View style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
<TextInput
style={styles.bookInput}
value={newName}
onChangeText={setNewName}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitCreate(); }}
/>
<Pressable
onPress={() => { void commitCreate(); }}
disabled={!newName.trim() || busy}
hitSlop={6}
style={styles.iconBtn}
>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={styles.addRow}
onPress={() => { setEditingId(null); setAdding(true); }}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addLabel}>New address book</Text>
</Pressable>
)}
<View style={{ marginTop: spacing.sm }}>
<Button
variant="ghost"
size="sm"
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
>
Refresh
</Button>
</View>
</SettingsSection>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={books[0]?.name}
onImported={() => { void fetchContacts(); }}
/>
<Dialog
visible={deleteTarget !== null}
title="Delete address book"
message={
deleteTarget
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.`
: ''
}
variant="destructive"
confirmText="Delete"
onConfirm={() => { void confirmDelete(); }}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
bookRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
bookName: { ...typography.body, color: c.text },
bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 },
bookInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
},
addLabel: { ...typography.body, color: c.primary },
});
}
@@ -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 },
});
}
@@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings-
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
readPushAccountIds,
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
} from '../../lib/push-notifications';
const SOUNDS: { value: NotificationSound; label: string }[] = [
@@ -40,6 +41,7 @@ export function NotificationSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -106,10 +108,20 @@ export function NotificationSettings() {
setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' });
try {
await teardownPushNotifications();
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId);
}
// The relay base URL is a device-wide setting shared with any other
// accounts that are still using push. Only clear it (and reset the UI
// to "not configured") when no accounts are using push any more —
// otherwise the remaining accounts would lose their relay URL and
// their next setup attempt would no-op.
const remaining = await readPushAccountIds();
if (remaining.length === 0) {
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
}
setPushStatus({ kind: 'idle' });
} catch (error) {
setPushStatus({
@@ -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>
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
offsetToMinutesBefore,
minutesBeforeToOffset,
alertsToReminders,
remindersToAlerts,
formatReminder,
} from '../calendar-alerts';
describe('calendar-alerts', () => {
describe('offsetToMinutesBefore', () => {
it('parses minute offsets', () => {
expect(offsetToMinutesBefore('-PT15M')).toBe(15);
expect(offsetToMinutesBefore('-PT30M')).toBe(30);
});
it('parses hour and day offsets', () => {
expect(offsetToMinutesBefore('-PT1H')).toBe(60);
expect(offsetToMinutesBefore('-P1D')).toBe(60 * 24);
});
it('treats PT0S as at-time (0)', () => {
expect(offsetToMinutesBefore('PT0S')).toBe(0);
});
it('returns negative minutesBefore for post-start offsets', () => {
expect(offsetToMinutesBefore('PT10M')).toBe(-10);
});
it('returns null for garbage', () => {
expect(offsetToMinutesBefore('nonsense')).toBeNull();
});
});
describe('minutesBeforeToOffset', () => {
it('round-trips through offsetToMinutesBefore', () => {
for (const m of [0, 5, 15, 30, 60, 120, 60 * 24, 60 * 24 * 7]) {
expect(offsetToMinutesBefore(minutesBeforeToOffset(m))).toBe(m);
}
});
it('emits PT0S at time of event', () => {
expect(minutesBeforeToOffset(0)).toBe('PT0S');
});
});
describe('alertsToReminders / remindersToAlerts', () => {
it('extracts and de-dupes reminders, sorted by lead time desc', () => {
const reminders = alertsToReminders({
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
b: { trigger: { '@type': 'OffsetTrigger', offset: '-P1D' }, action: 'display' },
c: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
});
expect(reminders).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 15 }]);
});
it('builds an alerts map from reminders', () => {
const alerts = remindersToAlerts([{ minutesBefore: 15 }, { minutesBefore: 0 }]);
expect(alerts && Object.keys(alerts)).toHaveLength(2);
const offsets = Object.values(alerts!).map((a) => a.trigger.offset);
expect(offsets).toContain('-PT15M');
expect(offsets).toContain('PT0S');
});
it('returns undefined for an empty reminder list', () => {
expect(remindersToAlerts([])).toBeUndefined();
});
it('round-trips alerts → reminders → alerts', () => {
const reminders = [{ minutesBefore: 60 }, { minutesBefore: 10 }];
const back = alertsToReminders(remindersToAlerts(reminders));
expect(back).toEqual([{ minutesBefore: 60 }, { minutesBefore: 10 }]);
});
});
describe('formatReminder', () => {
it('formats common offsets', () => {
expect(formatReminder(0)).toBe('At time of event');
expect(formatReminder(15)).toBe('15 minutes before');
expect(formatReminder(60)).toBe('1 hour before');
expect(formatReminder(60 * 24)).toBe('1 day before');
expect(formatReminder(60 * 24 * 7)).toBe('1 week before');
});
});
});
+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');
});
});
@@ -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');
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import {
parseVCard,
generateVCard,
contactToVCard,
detectDuplicates,
} from '../vcard';
import type { ContactCard } from '../../api/types';
describe('parseVCard', () => {
it('parses a basic 3.0 card with name, email, and phone', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Jane Doe',
'N:Doe;Jane;;;',
'EMAIL;TYPE=WORK:jane@example.com',
'TEL;TYPE=CELL:+1-555-0100',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
expect(card).toBeTruthy();
const emails = Object.values(card.emails ?? {});
expect(emails[0].address).toBe('jane@example.com');
expect(emails[0].contexts).toEqual({ work: true });
const phones = Object.values(card.phones ?? {});
expect(phones[0].number).toBe('+1-555-0100');
expect(phones[0].features).toEqual({ cell: true });
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
expect(given).toBe('Jane');
expect(surname).toBe('Doe');
});
it('parses multiple cards in one file', () => {
const vcf = [
'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD',
'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD',
].join('\r\n');
expect(parseVCard(vcf)).toHaveLength(2);
});
it('unfolds folded lines and decodes escaped values', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Long Name',
'NOTE:line one\\nline two\\, still going',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
const note = Object.values(card.notes ?? {})[0]?.note;
expect(note).toBe('line one\nline two, still going');
});
it('parses CATEGORIES into keywords', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(card.keywords).toEqual({ Friends: true, VIP: true });
});
it('skips cards with neither a name nor an email', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD';
expect(parseVCard(vcf)).toHaveLength(0);
});
it('strips mailto:/tel: URI schemes', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com');
expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111');
});
});
describe('generateVCard', () => {
it('round-trips name and email through parse → generate → parse', () => {
const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD';
const [parsed] = parseVCard(original);
const generated = generateVCard([parsed]);
expect(generated).toContain('FN:Jane Doe');
expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com');
const [reparsed] = parseVCard(generated);
expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com');
});
it('contactToVCard emits a single BEGIN/END block', () => {
const card: ContactCard = {
id: 'c1',
addressBookIds: {},
name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] },
emails: { e0: { address: 'solo@x.com' } },
};
const out = contactToVCard(card);
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
expect(out.match(/END:VCARD/g)).toHaveLength(1);
});
});
describe('detectDuplicates', () => {
const existing: ContactCard[] = [
{ id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } },
];
it('flags an incoming contact that shares an email (case-insensitive)', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } },
{ id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } },
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.has(0)).toBe(false);
expect(dupes.get(1)).toBe('x1');
});
it('returns an empty map when nothing overlaps', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } },
];
expect(detectDuplicates(existing, incoming).size).toBe(0);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { eachYearOfInterval, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types';
import { getContactDisplayName } from './contact-utils';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
// A read-only virtual calendar holding generated birthday events.
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name,
color: BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
myRights: {
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayWrite: false,
mayDelete: false,
mayRSVP: false,
},
};
}
function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
if (date.startsWith('--')) {
const m = date.match(/^--(\d{2})-(\d{2})$/);
return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null;
}
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
// PartialDate
if (date.month && date.day) {
return { month: date.month, day: date.day, year: date.year || undefined };
}
return null;
}
// Generate virtual all-day birthday events for the given range from contacts
// that carry a `birth` anniversary.
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return events;
const years = eachYearOfInterval({ start, end });
const endYear = end.getFullYear();
if (!years.some((y) => y.getFullYear() === endYear)) {
years.push(new Date(endYear, 0, 1));
}
for (const contact of contacts) {
if (!contact.anniversaries) continue;
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
if (anniversary.kind !== 'birth') continue;
const parsed = parseBirthdayDate(anniversary.date);
if (!parsed) continue;
const displayName = getContactDisplayName(contact);
if (!displayName) continue;
for (const yearDate of years) {
const year = yearDate.getFullYear();
// Feb 29 in a non-leap year: clamp to Feb 28.
let occMonth = parsed.month;
let occDay = parsed.day;
const occurrence = new Date(year, occMonth - 1, occDay);
if (occurrence.getMonth() !== occMonth - 1) {
occurrence.setDate(0);
occMonth = occurrence.getMonth() + 1;
occDay = occurrence.getDate();
}
if (occurrence < start || occurrence > end) continue;
const mm = String(occMonth).padStart(2, '0');
const dd = String(occDay).padStart(2, '0');
const age = parsed.year ? year - parsed.year : undefined;
const ageText = age && age > 0 ? ` (${age})` : '';
events.push({
id: `birthday-${contact.id}-${key}-${year}`,
'@type': 'Event',
uid: `birthday-${contact.id}-${key}`,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
title: `🎂 ${displayName}${ageText}`,
start: `${year}-${mm}-${dd}T00:00:00`,
duration: 'P1D',
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'free',
color: BIRTHDAY_CALENDAR_COLOR,
});
}
}
}
return events;
}
+103
View File
@@ -0,0 +1,103 @@
import type { Alert, CalendarEvent } from '../api/types';
// A reminder, expressed as "minutes before the event start". 0 means "at the
// time of the event". Negative values (reminder *after* start) are preserved
// on round-trip but the picker only offers non-negative presets.
export interface Reminder {
/** Minutes before start. 0 = at time of event. */
minutesBefore: number;
}
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes.
// Positive return value = minutes *before* the anchor (JSCalendar offsets for
// reminders are negative, e.g. "-PT15M" fires 15 min before start).
export function offsetToMinutesBefore(offset: string | undefined): number | null {
if (!offset) return null;
const m = DURATION_RE.exec(offset);
if (!m) return null;
const negative = m[1] === '-';
const weeks = parseInt(m[2] || '0', 10);
const days = parseInt(m[3] || '0', 10);
const hours = parseInt(m[4] || '0', 10);
const minutes = parseInt(m[5] || '0', 10);
const seconds = parseInt(m[6] || '0', 10);
const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60);
// A negative offset (fires before start) is a positive "minutesBefore".
// `|| 0` collapses -0 to 0 so callers get a clean "at time of event".
return (negative ? totalMinutes : -totalMinutes) || 0;
}
export function minutesBeforeToOffset(minutesBefore: number): string {
if (minutesBefore === 0) return 'PT0S';
const sign = minutesBefore > 0 ? '-' : '';
let mins = Math.abs(minutesBefore);
const days = Math.floor(mins / (24 * 60));
mins -= days * 24 * 60;
const hours = Math.floor(mins / 60);
mins -= hours * 60;
let out = 'P';
if (days > 0) out += `${days}D`;
if (hours > 0 || mins > 0) {
out += 'T';
if (hours > 0) out += `${hours}H`;
if (mins > 0) out += `${mins}M`;
}
if (out === 'P') out = 'PT0M';
return `${sign}${out}`;
}
// Pull the list of reminders out of an event's `alerts` map. Only display-style
// offset alerts relative to start are surfaced in the editor; anything exotic is
// dropped from the UI list (but the event keeps it until the user saves).
export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
if (!alerts) return [];
const out: Reminder[] = [];
for (const alert of Object.values(alerts)) {
const trigger = alert?.trigger;
if (!trigger) continue;
if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker
const minutesBefore = offsetToMinutesBefore(trigger.offset);
if (minutesBefore === null) continue;
out.push({ minutesBefore });
}
// De-dupe + sort soonest-to-event last (largest lead time first).
const seen = new Set<number>();
return out
.filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true)))
.sort((a, b) => b.minutesBefore - a.minutesBefore);
}
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
if (reminders.length === 0) return undefined;
const alerts: Record<string, Alert> = {};
reminders.forEach((r, i) => {
alerts[`reminder-${i + 1}`] = {
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
action: 'display',
};
});
return alerts;
}
export function formatReminder(minutesBefore: number): string {
if (minutesBefore === 0) return 'At time of event';
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
if (minutesBefore < 60 * 24) {
const h = minutesBefore / 60;
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
}
if (minutesBefore < 60 * 24 * 7) {
const d = minutesBefore / (60 * 24);
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
}
const w = minutesBefore / (60 * 24 * 7);
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
}
// Presets offered by the reminder picker (minutes before start).
export const REMINDER_PRESETS: number[] = [
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
];
+148
View File
@@ -0,0 +1,148 @@
import type { CalendarEvent, Participant, Email, Attachment } from '../api/types';
// ─── Address helpers ─────────────────────────────────────
export function normalizeEmail(value?: string | null): string | null {
if (!value) return null;
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
return normalized || null;
}
export function getParticipantEmail(p: Participant): string | null {
const direct = normalizeEmail(p.email);
if (direct) return direct;
if (p.calendarAddress) {
const addr = normalizeEmail(p.calendarAddress);
if (addr) return addr;
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
const n = normalizeEmail(addr);
if (n) return n;
}
}
return null;
}
export function isOrganizerParticipant(p: Participant): boolean {
return Boolean(p.roles?.owner || p.roles?.chair);
}
export function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) {
const e = getParticipantEmail(p);
if (e) return e;
}
}
}
if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress);
return null;
}
export function getOrganizerName(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p);
}
if (event.organizerCalendarAddress) {
for (const p of Object.values(event.participants)) {
if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p);
}
}
}
return getOrganizerEmail(event);
}
// Find the participant entry that matches one of the given user emails.
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
emails: string[],
): { id: string; participant: Participant } | null {
if (!event.participants || emails.length === 0) return null;
const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean));
for (const [id, p] of Object.entries(event.participants)) {
const e = getParticipantEmail(p);
if (e && wanted.has(e)) return { id, participant: p };
}
return null;
}
// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP
// reply (Stalwart exposes the organizer as organizerCalendarAddress).
export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, string> | null {
if (event.replyTo) return event.replyTo;
if (event.organizerCalendarAddress) {
const addr = event.organizerCalendarAddress.startsWith('mailto:')
? event.organizerCalendarAddress
: `mailto:${event.organizerCalendarAddress}`;
return { imip: addr };
}
return null;
}
// ─── iMIP method detection ───────────────────────────────
export type InvitationMethod =
| 'publish' | 'request' | 'reply' | 'add'
| 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown';
const KNOWN_METHODS = new Set<InvitationMethod>([
'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter',
]);
function normalizeMethod(value?: string | null): InvitationMethod {
if (!value) return 'unknown';
const v = value.trim().toLowerCase();
return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown';
}
// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST`
// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source.
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
const m = rawText.match(/^METHOD:(\S+)/m);
return m ? normalizeMethod(m[1]) : 'unknown';
}
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
if (!event.participants) return false;
return Object.values(event.participants).some(
(p) =>
p.roles?.attendee &&
!isOrganizerParticipant(p) &&
(p.participationStatus !== 'needs-action' || !!p.participationComment),
);
}
export function inferInvitationMethod(event: Partial<CalendarEvent>): InvitationMethod {
if (event.status === 'cancelled') return 'cancel';
if (looksLikeReply(event)) return 'reply';
if (event.participants && Object.keys(event.participants).length > 0) {
if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) {
return 'request';
}
}
return 'unknown';
}
// ─── Calendar attachment detection ───────────────────────
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
function isCalendarType(type?: string | null): boolean {
if (!type) return false;
const base = type.split(';')[0].trim().toLowerCase();
return CAL_MIME.has(base);
}
export function findCalendarAttachment(email: Pick<Email, 'attachments'>): Attachment | null {
if (!email.attachments) return null;
for (const att of email.attachments) {
const name = att.name?.toLowerCase() || '';
if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) {
return att;
}
}
return null;
}
+52 -1
View File
@@ -435,12 +435,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 {
+35
View File
@@ -0,0 +1,35 @@
import { CAPABILITIES } from '../api/types';
import { useAuthStore } from '../stores/auth-store';
// When the session is null (cold start, offline restore) we assume features
// are available so they don't flicker off mid-restore. Once the live session
// arrives, the real capability set takes over.
function sessionHasCapability(capability: string): boolean {
const session = useAuthStore.getState().session;
if (!session) return true;
return capability in session.capabilities;
}
export function hasCalendarCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CALENDARS);
}
export function hasContactsCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CONTACTS);
}
export function hasFilesCapability(): boolean {
return sessionHasCapability(CAPABILITIES.FILES);
}
export function useHasCalendar(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
}
export function useHasContacts(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
}
export function useHasFiles(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
}
+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;
}
+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 }];
}
+84 -2
View File
@@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import type { ReleaseAsset } from '../api/updates';
import { Sha256 } from './sha256';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
// chunks are faster but balloon the JS string buffer (base64 reads on this
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
const HASH_CHUNK_BYTES = 256 * 1024;
export interface InstallProgress {
phase: 'downloading' | 'installing';
phase: 'downloading' | 'verifying' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
@@ -19,10 +25,51 @@ export interface InstallProgress {
export type InstallProgressListener = (p: InstallProgress) => void;
export interface InstallParams {
asset: ReleaseAsset;
/** Optional lower-case hex SHA-256. When present, mismatched downloads abort. */
expectedSha256?: string | null;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
const hasher = new Sha256();
let offset = 0;
while (offset < totalBytes || totalBytes === 0) {
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
encoding: FileSystemLegacy.EncodingType.Base64,
position: offset,
length,
});
if (!chunkB64) break;
const bytes = base64ToBytes(chunkB64);
if (bytes.length === 0) break;
hasher.update(bytes);
offset += bytes.length;
if (totalBytes > 0) {
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
}
if (bytes.length < length) break;
}
return hasher.digestHex();
}
export async function downloadAndInstallApk(
asset: ReleaseAsset,
params: InstallParams | ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
// Backwards-compat: callers used to pass the asset directly. Promote to the
// params shape so the verification path is opt-in by passing a checksum.
const { asset, expectedSha256 }: InstallParams =
'asset' in params ? params : { asset: params, expectedSha256: null };
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
@@ -53,6 +100,33 @@ export async function downloadAndInstallApk(
throw new Error('APK download failed');
}
// Size sanity check. GitHub's `assets[].size` is signed by their TLS cert,
// so a byte-count mismatch is a strong signal the file was truncated /
// tampered with in transport. Cheap to compute, fail-fast.
const info = await FileSystemLegacy.getInfoAsync(result.uri);
const actualSize = (info as { size?: number }).size ?? 0;
if (asset.size > 0 && actualSize !== asset.size) {
await tryDelete(result.uri);
throw new Error(
`APK size mismatch: expected ${asset.size} bytes, got ${actualSize}`,
);
}
// SHA-256 verification when the release publishes one. Without it, we still
// get Android's package-signer check at install time — which rejects an APK
// signed with a different key — but that fails late (after the user sees a
// confusing system dialog). Bailing here surfaces the problem cleanly.
if (expectedSha256) {
onProgress?.({ phase: 'verifying', progress: 0 });
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await tryDelete(result.uri);
throw new Error(
`APK checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
}
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
@@ -64,6 +138,14 @@ export async function downloadAndInstallApk(
});
}
async function tryDelete(uri: string): Promise<void> {
try {
await FileSystemLegacy.deleteAsync(uri, { idempotent: true });
} catch {
/* best effort */
}
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
+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));
}
+250
View File
@@ -0,0 +1,250 @@
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
// Webmail-mediated login. The app opens the webmail's normal login page with
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
// uses its existing password and OAuth flows, and once the user is signed
// in, redirects back to the app's custom scheme with credentials packed into
// the URL fragment. Fragments aren't sent to the server, so password and
// token material don't appear in HTTP access logs along the way.
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export interface OAuthTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: number; // epoch ms
tokenEndpoint: string;
clientId: string;
}
export type HandoffResult =
| {
flow: 'password';
serverUrl: string;
username: string;
password: string;
}
| {
flow: 'oauth';
serverUrl: string;
tokens: OAuthTokens;
};
export class HandoffError extends Error {}
export class HandoffCancelledError extends HandoffError {
constructor() {
super('Sign-in cancelled');
}
}
function randomState(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
mobile_state: state,
});
return `${base}/login?${params.toString()}`;
}
function parseFragment(url: string): URLSearchParams {
const hashIdx = url.indexOf('#');
if (hashIdx === -1) return new URLSearchParams();
return new URLSearchParams(url.slice(hashIdx + 1));
}
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
if (result.type === 'cancel' || result.type === 'dismiss') {
throw new HandoffCancelledError();
}
if (result.type !== 'success' || !result.url) {
throw new HandoffError(`Sign-in failed: ${result.type}`);
}
const params = parseFragment(result.url);
const err = params.get('error');
if (err) throw new HandoffError(err);
// CSRF guard: the state we generated must round-trip through the webmail
// unchanged. A mismatch means the redirect didn't come from the flow we
// started, and the rest of the fragment shouldn't be trusted.
if (params.get('state') !== state) {
throw new HandoffError('State mismatch');
}
const flow = params.get('flow');
const serverUrl = params.get('server_url');
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
if (flow === 'password') {
const username = params.get('username');
const password = params.get('password');
if (!username || !password) {
throw new HandoffError('Sign-in response missing credentials');
}
return { flow: 'password', serverUrl, username, password };
}
if (flow === 'oauth') {
const accessToken = params.get('access_token');
const tokenEndpoint = params.get('token_endpoint');
const clientId = params.get('client_id');
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
const refreshToken = params.get('refresh_token') ?? undefined;
const expiresIn = params.get('expires_in');
return {
flow: 'oauth',
serverUrl,
tokens: {
accessToken,
refreshToken,
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
tokenEndpoint,
clientId,
},
};
}
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,
},
};
}
// 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.
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
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 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,
};
}
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,
+132 -48
View File
@@ -8,13 +8,22 @@ import {
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
import {
deviceClientIdKey,
lastNotifiedKey,
migrateLegacyPushKeys,
readPushAccountIds,
} from './push-notifications';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
// import the Zustand store (would pull in React) so we read AsyncStorage
// directly. Keep this in sync if the store key ever changes.
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
// the user's active account at the end of the headless task so we can leave
// the jmapClient singleton in a state consistent with what the UI expects
// when the app resumes.
const ACCOUNT_REGISTRY_KEY = 'account-registry';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
@@ -41,76 +50,151 @@ interface ShowNotificationOptions {
emailId: string;
threadId: string;
subject?: string;
accountId: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
// received the push on (the URL slot from createPushSubscription). The exact
// key the relay uses isn't part of any client-controlled contract, so rather
// than guessing a field name we scan all string values in the data payload
// and look for one that matches a deviceClientId we've registered locally.
// Returns null if nothing matches (e.g. legacy relay payload format) so the
// caller falls back to iterating every known account.
async function identifyAccountFromFcmData(
data: unknown,
accountIds: string[],
): Promise<string | null> {
if (!data || typeof data !== 'object') return null;
const reverseMap = new Map<string, string>();
for (const accountId of accountIds) {
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (dcid) reverseMap.set(dcid, accountId);
}
if (reverseMap.size === 0) return null;
for (const value of Object.values(data as Record<string, unknown>)) {
if (typeof value === 'string') {
const match = reverseMap.get(value);
if (match) return match;
}
}
return null;
}
// Read the persisted active account directly from AsyncStorage (Zustand's
// persist middleware stores the JSON-serialised state under its `name`).
async function readActiveAccountId(): Promise<string | null> {
try {
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
return parsed.state?.activeAccountId ?? null;
} catch {
return null;
}
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime - keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
export async function pushBackgroundTask(data: unknown): Promise<void> {
let activeAccountId: string | null = null;
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
await migrateLegacyPushKeys();
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const accountIds = await readPushAccountIds();
if (accountIds.length === 0) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
activeAccountId = await readActiveAccountId();
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const matched = await identifyAccountFromFcmData(data, accountIds);
const accountsToCheck = matched ? [matched] : accountIds;
const emailId = ids[0];
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
for (const accountId of accountsToCheck) {
try {
await processAccountForPush(accountId);
} catch (err) {
console.warn(
'[push] background check failed for account',
accountId,
err instanceof Error ? err.message : err,
);
}
}
} catch (error) {
console.warn(
'[push] background task failed:',
error instanceof Error ? error.message : error,
);
} finally {
// Leave the singleton bound to the user's active account so the UI sees
// a consistent jmapClient state when the app resumes. Without this, the
// last account processed above would remain loaded and the UI's cached
// email-store data would not match what the next JMAP request returns.
if (activeAccountId) {
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
}
}
}
async function processAccountForPush(accountId: string): Promise<void> {
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const emailId = ids[0];
const lastKey = lastNotifiedKey(accountId);
const previouslyNotified = await AsyncStorage.getItem(lastKey);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
await AsyncStorage.setItem(lastKey, emailId);
}
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
if (!match) return '#2563eb';
+205 -43
View File
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. deviceClientId also routes to the relay slot.
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
// setupPushNotifications / pushBackgroundTask call, then deleted.
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
export function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
export function lastNotifiedKey(accountId: string): string {
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((s): s is string => typeof s === 'string');
} catch {
return [];
}
}
async function writePushAccountIds(ids: string[]): Promise<void> {
const deduped = Array.from(new Set(ids));
if (deduped.length === 0) {
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
}
}
// One-shot migration from the pre-multi-account schema. If the legacy
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
// setup: reuse the legacy deviceClientId and JMAP subscription id under
// the new per-account keys so the user doesn't lose push on upgrade.
export async function migrateLegacyPushKeys(): Promise<void> {
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
if (!legacyAccountId) return;
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
if (legacyDcid) {
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
}
if (legacySubId) {
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
}
if (legacyLastId) {
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
}
const ids = await readPushAccountIds();
if (!ids.includes(legacyAccountId)) {
await writePushAccountIds([...ids, legacyAccountId]);
}
await AsyncStorage.multiRemove([
LEGACY_PUSH_ACCOUNT_ID_KEY,
LEGACY_DEVICE_CLIENT_ID_KEY,
LEGACY_SUBSCRIPTION_ID_KEY,
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
]);
}
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
@@ -72,11 +148,12 @@ function randomClientId(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(): Promise<string> {
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
const key = deviceClientIdKey(accountId);
const existing = await AsyncStorage.getItem(key);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
await AsyncStorage.setItem(key, next);
return next;
}
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
const deviceClientId = await getOrCreateDeviceClientId();
await setStoredRelayBaseUrl(relayBaseUrl);
// Persist the account id so the background push task can reload credentials
// from SecureStore without the main app being running.
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl) {
await AsyncStorage.setItem(
PUSH_ACCOUNT_ID_KEY,
generateAccountId(username, serverUrl),
);
if (!username || !serverUrl) {
throw new Error('No account loaded - cannot set up push');
}
const accountId = generateAccountId(username, serverUrl);
await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId);
await setStoredRelayBaseUrl(relayBaseUrl);
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually so the headless
// task can identify the source account via the FCM data payload.
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
const existingSubs = await listPushSubscriptions().catch(() => []);
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(match);
if (refreshed) {
await addPushAccountId(accountId);
return { subscriptionId: storedServerId, verified: true };
}
// Server rejected the refresh (likely the subscription was already
// deleted server-side) - drop the stale id and recreate below.
await destroyPushSubscription(storedServerId).catch(() => undefined);
}
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this device.
// 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".
// 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,
);
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
await AsyncStorage.setItem(subKey, serverAssignedId);
await addPushAccountId(accountId);
return { subscriptionId: serverAssignedId, verified: true };
}
async function addPushAccountId(accountId: string): Promise<void> {
const ids = await readPushAccountIds();
if (!ids.includes(accountId)) {
await writePushAccountIds([...ids, accountId]);
}
}
// Push the subscription's expires forward when it's getting close to the
// server's ceiling. Returns false if the server rejects the update, which the
// caller treats as "recreate".
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
}
}
async function deregisterFromRelay(
relayBaseUrl: string,
deviceClientId: string,
): Promise<void> {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws - callers treat teardown as best effort.
* Tear down push for a single account. Destroys that account's JMAP
* subscription (assumes the jmapClient is currently authenticated to that
* account; the active-account logout flow guarantees this) and tells the
* relay to drop its mapping. Other accounts' push setups are untouched.
*
* If no accounts have push left after removal, also deletes the FCM token
* so the device stops receiving FCM messages entirely. Never throws -
* callers treat teardown as best effort.
*/
export async function teardownPushNotificationsForAccount(
accountId: string,
): Promise<void> {
await migrateLegacyPushKeys();
const subKey = subscriptionIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const dcidKey = deviceClientIdKey(accountId);
const storedDcid = await AsyncStorage.getItem(dcidKey);
const relayBaseUrl = await getStoredRelayBaseUrl();
if (storedSubId) {
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
// If this was the last account with push, kill the FCM token so the device
// truly goes silent. Otherwise the token stays alive so the remaining
// accounts keep receiving pushes.
if (remaining.length === 0) {
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
}
/**
* Tear down push for ALL accounts on this device. Used by the logout-all
* flow; best-effort because we typically aren't authenticated to every
* account's JMAP server at the moment we need to call destroy on it. The
* FCM token is always deleted so no push gets through regardless.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const relayBaseUrl = await getStoredRelayBaseUrl();
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
await migrateLegacyPushKeys();
if (storedId) {
await destroyPushSubscription(storedId).catch(() => undefined);
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
const accountIds = await readPushAccountIds();
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of accountIds) {
const subKey = subscriptionIdKey(accountId);
const dcidKey = deviceClientIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const storedDcid = await AsyncStorage.getItem(dcidKey);
if (storedSubId) {
// Will only succeed if the jmapClient happens to be authenticated to
// this account right now. We don't switch the client to attempt each
// one; the subscription will expire server-side instead (90-day TTL).
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
}
if (relayBaseUrl && deviceClientId) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
// Identifies which logged-in account the notification was generated for.
// Optional for back-compat: older notifications already on the system tray
// won't carry this and will fall back to the active account on tap.
accountId?: string;
}
// Returns - and clears - any pending "notification tap" that launched the app
+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;
}
+131
View File
@@ -0,0 +1,131 @@
// Minimal SHA-256 implementation. Used to verify APK update downloads against
// the checksum published in the GitHub release body — the JS runtime has no
// built-in `crypto.subtle.digest` and we don't currently depend on
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
// MB on a modern phone, which is fine for the rare update flow.
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
export class Sha256 {
private h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
private buffer = new Uint8Array(64);
private bufferLen = 0;
private byteCount = 0;
update(bytes: Uint8Array): void {
let i = 0;
this.byteCount += bytes.length;
while (i < bytes.length) {
const room = 64 - this.bufferLen;
const chunk = Math.min(room, bytes.length - i);
this.buffer.set(bytes.subarray(i, i + chunk), this.bufferLen);
this.bufferLen += chunk;
i += chunk;
if (this.bufferLen === 64) {
this.process(this.buffer);
this.bufferLen = 0;
}
}
}
digestHex(): string {
// Final block: append 0x80, pad with zeros, then 8-byte big-endian length.
const totalBits = this.byteCount * 8;
this.buffer[this.bufferLen++] = 0x80;
if (this.bufferLen > 56) {
while (this.bufferLen < 64) this.buffer[this.bufferLen++] = 0;
this.process(this.buffer);
this.bufferLen = 0;
}
while (this.bufferLen < 56) this.buffer[this.bufferLen++] = 0;
// 64-bit length, big-endian. We can't represent >2^53 - JS bitops are
// 32-bit, so split into hi/lo words.
const hi = Math.floor(totalBits / 0x100000000);
const lo = totalBits >>> 0;
this.buffer[56] = (hi >>> 24) & 0xff;
this.buffer[57] = (hi >>> 16) & 0xff;
this.buffer[58] = (hi >>> 8) & 0xff;
this.buffer[59] = hi & 0xff;
this.buffer[60] = (lo >>> 24) & 0xff;
this.buffer[61] = (lo >>> 16) & 0xff;
this.buffer[62] = (lo >>> 8) & 0xff;
this.buffer[63] = lo & 0xff;
this.process(this.buffer);
let out = '';
for (let i = 0; i < 8; i++) {
out += this.h[i].toString(16).padStart(8, '0');
}
return out;
}
private process(block: Uint8Array): void {
const w = new Uint32Array(64);
for (let i = 0; i < 16; i++) {
w[i] =
(block[i * 4] << 24) |
(block[i * 4 + 1] << 16) |
(block[i * 4 + 2] << 8) |
block[i * 4 + 3];
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3];
let e = this.h[4], f = this.h[5], g = this.h[6], hh = this.h[7];
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) | 0;
hh = g;
g = f;
f = e;
e = (d + temp1) | 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) | 0;
}
this.h[0] = (this.h[0] + a) | 0;
this.h[1] = (this.h[1] + b) | 0;
this.h[2] = (this.h[2] + c) | 0;
this.h[3] = (this.h[3] + d) | 0;
this.h[4] = (this.h[4] + e) | 0;
this.h[5] = (this.h[5] + f) | 0;
this.h[6] = (this.h[6] + g) | 0;
this.h[7] = (this.h[7] + hh) | 0;
}
}
export function sha256Hex(bytes: Uint8Array): string {
const h = new Sha256();
h.update(bytes);
return h.digestHex();
}
@@ -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 };
}
+20
View File
@@ -0,0 +1,20 @@
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
// available (Hermes/React Native 0.81 exposes it), falling back to a
// Math.random implementation. These IDs are only used as temporary client-side
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
// the Math.random fallback's weaker entropy is acceptable.
export function generateUUID(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c?.randomUUID) {
try {
return c.randomUUID();
} catch {
// fall through to the manual implementation
}
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+1203 -116
View File
File diff suppressed because it is too large Load Diff
+4 -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;
};
@@ -23,6 +24,8 @@ export type RootStackParamList = {
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
GroupDetail: { groupId: string };
AddAccount: undefined;
Scheduled: undefined;
UnifiedInbox: undefined;
};
export type MainTabsParamList = {
+143 -4
View File
@@ -16,6 +16,7 @@ import {
CalendarDays,
LayoutGrid,
List as ListIcon,
ListChecks,
Menu,
} from 'lucide-react-native';
import {
@@ -47,11 +48,20 @@ import {
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
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,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store';
import type { Calendar, CalendarEvent } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -108,6 +118,12 @@ export default function CalendarScreen() {
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
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());
const [selectedDate, setSelectedDate] = React.useState(new Date());
@@ -125,6 +141,9 @@ export default function CalendarScreen() {
const [modalVisible, setModalVisible] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
const [sidebarVisible, setSidebarVisible] = React.useState(false);
const [tasksVisible, setTasksVisible] = React.useState(false);
const [importVisible, setImportVisible] = React.useState(false);
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
@@ -134,12 +153,69 @@ export default function CalendarScreen() {
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const importEvents = useCalendarStore((s) => s.importEvents);
const tasks = useCalendarStore((s) => s.tasks);
const createTask = useCalendarStore((s) => s.createTask);
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const allCalendars = useCalendarStore((s) => s.calendars);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const storeCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
const allEvents = useCalendarStore((s) => s.events);
const storeEvents = useCalendarStore((s) => s.events);
// The birthday calendar is a client-side virtual calendar: its events are
// generated from contacts for the visible range and merged in alongside the
// server calendars. Toggling it on/off is instant (no refetch).
const birthdayEvents = React.useMemo(() => {
if (!showBirthdayCalendar) return [];
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
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 ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
[displayCalendars, showBirthdayCalendar],
);
const allEvents = React.useMemo(
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
[storeEvents, birthdayEvents],
);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
@@ -210,23 +286,30 @@ export default function CalendarScreen() {
setModalVisible(true);
}, []);
const isReadOnlyEvent = React.useCallback(
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect]);
}, [openEditDirect, isReadOnlyEvent]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent]);
}, [deleteEvent, isReadOnlyEvent]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -293,6 +376,15 @@ export default function CalendarScreen() {
</Text>
</View>
<View style={styles.headerActions}>
{enableCalendarTasks && (
<Pressable
style={styles.headerBtn}
onPress={() => setTasksVisible(true)}
hitSlop={6}
>
<ListChecks size={20} color={c.text} />
</Pressable>
)}
<View style={styles.viewToggle}>
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
const Icon =
@@ -397,6 +489,20 @@ export default function CalendarScreen() {
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
onRsvp={async (ev, participantId, status) => {
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
setDetailEvent((cur) =>
cur && cur.id === ev.id && cur.participants?.[participantId]
? {
...cur,
participants: {
...cur.participants,
[participantId]: { ...cur.participants[participantId], participationStatus: status },
},
}
: cur,
);
}}
/>
<EventModal
@@ -422,6 +528,39 @@ export default function CalendarScreen() {
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
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
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
onDelete={deleteTask}
/>
<ICalImportSheet
visible={importVisible}
// 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}
/>
<ICalSubscriptionSheet
visible={subscriptionsVisible}
onClose={() => setSubscriptionsVisible(false)}
/>
</SafeAreaView>
);
+244 -5
View File
@@ -9,8 +9,9 @@ import {
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText,
Undo2, Redo2, FileText, Clock, Check,
} from 'lucide-react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { File as FsFile } from 'expo-file-system';
@@ -33,6 +34,7 @@ import {
} from '../lib/contact-utils';
import { getIdentities } from '../api/identity';
import { sendEmail, type OutgoingAttachment } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import { uploadBlob } from '../api/blob';
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
import { stripDangerousTags } from '../lib/email-html';
@@ -210,6 +212,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
@@ -256,11 +260,17 @@ 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 [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.
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
const customDraftRef = React.useRef<Date>(new Date());
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const initialTo = React.useMemo<Recipient[]>(() => {
if (!replyTo) {
@@ -309,10 +319,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);
@@ -737,7 +749,96 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
};
const onSend = async () => {
// Translate an absolute "send at" time into the HOLDFOR seconds the server
// expects, clamping to what it actually supports. Returns null (and alerts)
// when scheduling isn't possible so the caller can abort.
const resolveHoldForScheduledAt = (date: Date): number | null => {
const seconds = Math.ceil((date.getTime() - Date.now()) / 1000);
if (seconds <= 0) {
Alert.alert(
t('email_composer.schedule_past_title', 'Pick a future time'),
t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'),
);
return null;
}
if (!jmapClient.hasDelayedSend()) {
Alert.alert(
t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'),
t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'),
);
return null;
}
const max = jmapClient.getMaxDelayedSend();
if (max > 0 && seconds > max) {
Alert.alert(
t('email_composer.schedule_too_late_title', 'Too far ahead'),
t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
);
return null;
}
return seconds;
};
// The Send button: applies the global undo-send delay when the server
// supports it, otherwise sends immediately.
const onSend = () => {
const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined;
void performSend(holdFor);
};
const onScheduleConfirm = (date: Date) => {
const holdFor = resolveHoldForScheduledAt(date);
if (holdFor == null) return;
setScheduleSheetOpen(false);
void performSend(holdFor, date);
};
const schedulePresets = React.useMemo(() => {
const now = new Date();
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
const tomorrowMorning = new Date(now);
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
tomorrowMorning.setHours(8, 0, 0, 0);
return [
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
{ label: t('email_composer.schedule_tomorrow', 'Tomorrow, 8:00 AM'), date: tomorrowMorning },
];
}, [t, scheduleSheetOpen]);
const startCustomPicker = () => {
customDraftRef.current = new Date(Date.now() + 3600 * 1000);
setScheduleSheetOpen(false);
setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date');
};
const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => {
if (event.type === 'dismissed' || !selected) {
setCustomStage(null);
return;
}
if (Platform.OS === 'ios') {
// Single spinner — keep it open, just remember the latest value.
customDraftRef.current = selected;
return;
}
// Android: combine the date step with the existing time, then ask for time.
if (customStage === 'date') {
const d = new Date(customDraftRef.current);
d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
customDraftRef.current = d;
setCustomStage('time');
return;
}
if (customStage === 'time') {
const d = new Date(customDraftRef.current);
d.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
setCustomStage(null);
onScheduleConfirm(d);
}
};
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
if (!(await passesAttachmentReminder())) return;
setSending(true);
@@ -775,7 +876,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const outgoing = [...inlineFromBody, ...fileAttachments];
await sendEmail(
const result = await sendEmail(
{
from,
to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })),
@@ -793,7 +894,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
},
primaryIdentity.id,
sentMailbox.id,
holdForSeconds,
);
// Confirm an explicit "send later" so the user knows it didn't go out
// now. The brief undo-send delay stays silent — it's meant to be
// invisible unless the user cancels from the Scheduled view.
if (scheduledAt && result.scheduled) {
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
Alert.alert(
t('email_composer.scheduled_title', 'Scheduled'),
t('email_composer.scheduled_body', 'Your message will be sent at {time}.').replace(
'{time}',
when.toLocaleString(),
),
);
}
navigation.goBack();
} catch (e) {
Alert.alert(
@@ -828,10 +943,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
>
<Paperclip size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setScheduleSheetOpen(true)}
style={styles.headerBtn}
hitSlop={8}
disabled={!canSend}
>
<Clock size={20} color={canSend ? c.text : c.textMuted} />
</Pressable>
<Button
variant="default"
size="sm"
onPress={() => { void onSend(); }}
onPress={onSend}
disabled={!canSend}
icon={
sending ? (
@@ -1083,6 +1206,93 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
</Modal>
{/* Schedule send sheet */}
<Modal
visible={scheduleSheetOpen}
transparent
animationType="fade"
onRequestClose={() => setScheduleSheetOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
{!jmapClient.hasDelayedSend() && (
<Text style={styles.scheduleWarning}>
{t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
</Text>
)}
{schedulePresets.map((preset) => (
<Pressable
key={preset.label}
style={styles.scheduleRow}
onPress={() => onScheduleConfirm(preset.date)}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
<Text style={styles.scheduleRowTime}>
{preset.date.toLocaleString(undefined, {
weekday: 'short', hour: 'numeric', minute: '2-digit',
})}
</Text>
</Pressable>
))}
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
<Check size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>
{t('email_composer.schedule_custom', 'Pick date & time…')}
</Text>
</Pressable>
<Pressable
style={styles.scheduleCancel}
onPress={() => setScheduleSheetOpen(false)}
>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
display="spinner"
minimumDate={new Date()}
onChange={onCustomPickerChange}
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
style={styles.modalConfirm}
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
>
<Text style={styles.modalConfirmText}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
)}
{customStage !== null && Platform.OS !== 'ios' && (
<DateTimePicker
value={customDraftRef.current}
mode={customStage === 'time' ? 'time' : 'date'}
display="default"
minimumDate={customStage === 'date' ? new Date() : undefined}
onChange={onCustomPickerChange}
/>
)}
</SafeAreaView>
);
}
@@ -1302,5 +1512,34 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.primary,
},
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
scheduleCard: {
width: '100%',
maxWidth: 420,
backgroundColor: c.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
gap: spacing.xs,
},
scheduleWarning: {
...typography.caption,
color: c.error,
marginBottom: spacing.xs,
},
scheduleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
scheduleRowTime: { ...typography.caption, color: c.textMuted },
scheduleCancel: {
alignItems: 'center',
paddingVertical: spacing.sm,
marginTop: spacing.xs,
},
});
}
+25 -1
View File
@@ -12,7 +12,7 @@ import {
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
Clock, KeyRound,
Clock, KeyRound, FolderInput,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -27,6 +27,7 @@ import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -84,9 +85,11 @@ export default function ContactDetailScreen() {
const allContacts = useContactsStore((s) => s.contacts);
const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [moreOpen, setMoreOpen] = React.useState(false);
const [moveOpen, setMoveOpen] = React.useState(false);
if (!contact) {
return (
@@ -232,12 +235,26 @@ export default function ContactDetailScreen() {
}
};
const doMove = async (bookId: string) => {
setMoveOpen(false);
try {
await moveContactsToAddressBook([contact.id], bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const moreItems = [
!isGroup(contact) && {
icon: <Copy size={16} color={c.text} />,
label: 'Duplicate',
onPress: () => { void doDuplicate(); },
},
addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book',
onPress: () => setMoveOpen(true),
},
{
icon: <Share2 size={16} color={c.text} />,
label: 'Export vCard',
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
items={moreItems}
onClose={() => setMoreOpen(false)}
/>
<AddressBookPickerSheet
visible={moveOpen}
onClose={() => setMoveOpen(false)}
currentBookId={bookIds[0] ?? null}
onPick={(id) => { void doMove(id); }}
/>
</SafeAreaView>
);
}
+106 -2
View File
@@ -9,12 +9,13 @@ import {
ActivityIndicator,
RefreshControl,
ScrollView,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -25,7 +26,12 @@ import {
type ContactCategory,
} from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
import { ContactListRow } from '../components/contacts';
import {
ContactListRow,
AddressBookPickerSheet,
ContactImportSheet,
TagAssignSheet,
} from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
@@ -91,6 +97,8 @@ export default function ContactsScreen() {
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
@@ -99,6 +107,11 @@ export default function ContactsScreen() {
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState<Set<string>>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
const [importTargetOpen, setImportTargetOpen] = React.useState(false);
const [importTargetBookId, setImportTargetBookId] = React.useState<string | null>(null);
React.useEffect(() => {
void hydrate();
@@ -183,6 +196,45 @@ export default function ContactsScreen() {
await bulkDelete(ids);
};
// Default target for moves/imports: the currently-viewed address book, else
// the first available book.
const defaultBookId = React.useMemo(() => {
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
return addressBooks[0]?.id ?? null;
}, [selectedCategory, addressBooks]);
const handleBulkMove = async (bookId: string) => {
const ids = Array.from(selection);
setMoveSheetOpen(false);
clearSelection();
try {
await moveContactsToAddressBook(ids, bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const handleBulkTag = async (keyword: string) => {
const ids = Array.from(selection);
setTagSheetOpen(false);
clearSelection();
try {
await addKeywordToContacts(ids, keyword);
} catch (err) {
Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const openImport = () => {
setImportTargetBookId(defaultBookId);
setImportOpen(true);
};
const importTargetName = React.useMemo(
() => addressBooks.find((b) => b.id === importTargetBookId)?.name,
[addressBooks, importTargetBookId],
);
const title = categoryTitle(selectedCategory, addressBooks, groups);
// Top chip row: All / per-address-book / Groups
@@ -234,6 +286,22 @@ export default function ContactsScreen() {
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
{addressBooks.length > 0 && (
<Pressable
onPress={() => setMoveSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<FolderInput size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
>
<Search size={20} color={c.text} />
</Pressable>
<Pressable
onPress={openImport}
style={styles.headerIconBtn}
hitSlop={8}
>
<Upload size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
<AddressBookPickerSheet
visible={moveSheetOpen}
onClose={() => setMoveSheetOpen(false)}
onPick={(id) => { void handleBulkMove(id); }}
/>
<TagAssignSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
onPick={(kw) => { void handleBulkTag(kw); }}
/>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={importTargetName}
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
onImported={() => { void fetchContacts(); }}
/>
<AddressBookPickerSheet
visible={importTargetOpen}
onClose={() => setImportTargetOpen(false)}
currentBookId={importTargetBookId}
title="Import into address book"
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
/>
</SafeAreaView>
);
}

Some files were not shown because too many files have changed in this diff Show More