Compare commits

..
11 Commits
66 changed files with 8129 additions and 797 deletions
+15 -2
View File
@@ -46,6 +46,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
@@ -268,6 +269,7 @@ export default function App() {
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
@@ -276,10 +278,21 @@ export default function App() {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays });
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
// Drain the offline action queue (outbox) as soon as we have a live session,
// and again whenever the network comes back. The flush itself no-ops when
// there's nothing queued or the client isn't ready.
React.useEffect(() => {
if (!haveLiveSession) return;
void useOutboxStore.getState().flush();
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online) void useOutboxStore.getState().flush();
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
+1 -1
View File
@@ -1 +1 @@
0.1.39
0.1.44
+7
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -21,6 +22,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
+12 -1
View File
@@ -46,7 +46,18 @@ module.exports = {
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
plugins: [
'expo-secure-store',
'@react-native-community/datetimepicker',
'expo-localization',
[
'expo-camera',
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
},
],
],
extra: {
commit: COMMIT,
},
+8 -4
View File
@@ -821,10 +821,13 @@
},
"date_format": {
"label": "Date Format",
"description": "How dates should be displayed",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Custom"
"description": "How dates are shown in the email list",
"smart": "Smart (locale-aware)",
"relative": "Relative (1h ago, 2d ago)",
"full": "Always full date",
"preview_today": "Today:",
"preview_this_week": "This week:",
"preview_older": "Older:"
},
"time_format": {
"label": "Time Format",
@@ -1424,6 +1427,7 @@
"size_bytes": "Size in bytes",
"size_placeholder": "e.g., 1000000",
"system_managed": "System-managed rule",
"origin_external": "External",
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
"open_sieve_editor": "Open raw Sieve editor",
"fetch_error": "Failed to load filters",
+123 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.44",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -26,6 +27,7 @@
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -4757,6 +4759,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5204,6 +5212,26 @@
}
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6202,6 +6230,12 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -6318,6 +6352,12 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6819,6 +6859,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -6846,6 +6898,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@@ -8006,6 +8067,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-png": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
@@ -8217,6 +8284,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -8585,6 +8658,27 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -8989,6 +9083,12 @@
"node": ">= 0.8"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9226,6 +9326,21 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -9762,6 +9877,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.39",
"version": "0.1.44",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -32,6 +33,7 @@
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
+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);
}
+54 -3
View File
@@ -283,8 +283,10 @@ export async function getEmailChanges(
};
}
export async function getFullEmail(id: string): Promise<Email> {
const accountId = jmapClient.accountId;
export async function getFullEmail(id: string, accountIdOverride?: string): Promise<Email> {
// `accountIdOverride` lets the unified inbox open a message that lives under
// a group/shared account in the same session instead of the user's own.
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
@@ -321,6 +323,35 @@ export async function getFullEmails(ids: string[]): Promise<Email[]> {
return res.methodResponses[0][1].list as Email[];
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
* `importRawEmail` import step.
*/
export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
): Promise<string> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
emails: {
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
},
}, '0'],
]);
const result = res.methodResponses[0][1];
const notCreated = result?.notCreated?.['import-0'];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
}
const id = result?.created?.['import-0']?.id;
if (!id) throw new Error('Email import succeeded but no id was returned');
return id;
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
@@ -332,8 +363,9 @@ export async function getThread(threadId: string): Promise<Thread> {
export async function setEmailKeywords(
emailId: string,
keywords: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
]);
@@ -423,6 +455,25 @@ export async function restoreEmailMailboxes(
]);
}
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
// set, so this is idempotent — replaying it produces the same result no matter
// the current server state. The offline outbox relies on that to coalesce and
// safely retry move/archive/trash operations.
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay.
export async function destroyEmails(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
+7
View File
@@ -60,6 +60,13 @@ export class JMAPClient {
return this.credentials?.serverUrl ?? null;
}
// True when the session authenticates with a Bearer token (OAuth handoff or
// token login) rather than a username/password. Used by the security screen
// to hide password/TOTP management, which only applies to password accounts.
get usesBearerAuth(): boolean {
return !!this.credentials?.accessToken;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
+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'] };
}
+3
View File
@@ -19,6 +19,9 @@ export interface JMAPAccountInfo {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
// Per-account capability objects (RFC 8620 §2). Optional because not every
// server populates it; the Sieve panel reads its limits/extensions from here.
accountCapabilities?: Record<string, unknown>;
}
export type JMAPMethodCall = [string, Record<string, unknown>, string];
+84 -26
View File
@@ -20,6 +20,17 @@ const TOKEN_REFRESH_LEEWAY_MS = 60_000;
export interface UnifiedEmail extends Email {
/** Registry account id this message belongs to (not the JMAP account id). */
sourceAccountId: string;
/**
* JMAP account id the message lives under. Equals the registry account's
* primary JMAP account for own mail, or a group/shared owner account when
* the message came from a group inbox. Carried so opening can target the
* right account via an Email/get override.
*/
jmapAccountId: string;
/** True when this message belongs to a group/shared inbox, not the user's own. */
isShared: boolean;
/** Human label for the owning account when shared (e.g. "Support"). */
sharedLabel?: string;
}
export interface UnifiedInboxResult {
@@ -116,29 +127,15 @@ async function jmapPost(
return body.methodResponses ?? [];
}
async function fetchInboxForAccount(
accountId: string,
// Fetch the most recent inbox messages for a single JMAP account reachable via
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
// them with registry/sharing metadata.
async function fetchInboxEmailsForJmapAccount(
apiUrl: string,
authHeader: string,
jmapAccountId: string,
limit: number,
): Promise<UnifiedEmail[]> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (sessionRes.status === 401) throw new Error('Session expired');
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteApiUrl(session, baseUrl);
const jmapAccountId = resolveJmapAccountId(session);
if (!jmapAccountId) throw new Error('No mail account in session');
// Find the inbox mailbox.
): Promise<Email[]> {
const mailboxResponses = await jmapPost(
apiUrl,
authHeader,
@@ -149,7 +146,6 @@ async function fetchInboxForAccount(
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
if (!inbox) return [];
// Query + fetch the most recent messages.
const queryResponses = await jmapPost(
apiUrl,
authHeader,
@@ -170,19 +166,81 @@ async function fetchInboxForAccount(
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
}
async function fetchInboxForAccount(
accountId: string,
limit: number,
includeGroup: boolean,
): Promise<UnifiedEmail[]> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (sessionRes.status === 401) throw new Error('Session expired');
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteApiUrl(session, baseUrl);
const primaryJmapId = resolveJmapAccountId(session);
if (!primaryJmapId) throw new Error('No mail account in session');
// Always include the user's own primary inbox. When group inboxes are on,
// also scan every other (non-personal / shared) account reachable in the
// session — these are the group/shared mailboxes the user belongs to.
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
{ jmapId: primaryJmapId, isShared: false },
];
if (includeGroup) {
for (const [id, info] of Object.entries(session.accounts ?? {})) {
if (id === primaryJmapId) continue;
// Only accounts that expose the Mail capability can have an inbox.
const hasMail =
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
if (!hasMail) continue;
targets.push({ jmapId: id, isShared: true, label: info.name });
}
}
const perTarget = await Promise.all(
targets.map(async (target) => {
try {
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
return list.map<UnifiedEmail>((e) => ({
...e,
sourceAccountId: accountId,
jmapAccountId: target.jmapId,
isShared: target.isShared,
sharedLabel: target.isShared ? target.label : undefined,
}));
} catch {
// A single inaccessible shared account shouldn't sink the whole
// account's inbox; skip it and keep the others.
return [] as UnifiedEmail[];
}
}),
);
return perTarget.flat();
}
export async function fetchUnifiedInbox(
accountIds: string[],
perAccountLimit = 25,
opts: { includeGroup?: boolean } = {},
): Promise<UnifiedInboxResult> {
const { includeGroup = false } = opts;
const errors: Record<string, string> = {};
const settled = await Promise.all(
accountIds.map(async (id) => {
try {
return await fetchInboxForAccount(id, perAccountLimit);
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
} catch (err) {
errors[id] = err instanceof Error ? err.message : 'Failed to load';
return [] as UnifiedEmail[];
+161 -5
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';
@@ -45,29 +45,164 @@ 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;
}
// 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;
if (content > avail + 1) {
var scale = avail / content;
w.style.width = content + 'px';
w.style.transform = 'scale(' + scale + ')';
} else {
w.style.transform = 'none';
}
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
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 +252,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 {
@@ -273,6 +417,16 @@ 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),
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
@@ -316,13 +470,15 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// 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}
+8 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -14,12 +15,18 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
if (online) return null;
const queuedHint = queued > 0
? `${queued} change${queued === 1 ? '' : 's'} will sync when you reconnect`
: null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
return (
<View style={styles.bar}>
<CloudOff size={14} color={c.primaryForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{hint ? ` — ${hint}` : ''}
You are offline{suffix ? ` — ${suffix}` : ''}
</Text>
</View>
);
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
// Fires once with the raw decoded QR string. The parent is responsible for
// closing the modal and interpreting the payload.
onScanned: (data: string) => void;
}
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
React.useEffect(() => {
if (visible) handled.current = false;
}, [visible]);
const handleBarcode = React.useCallback(
(result: BarcodeScanningResult) => {
if (handled.current) return;
handled.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
</View>
) : (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
</Button>
</View>
)}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: '#ffffff' },
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: '#ffffff',
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hint: {
...typography.caption,
color: '#ffffff',
textAlign: 'center',
paddingHorizontal: spacing.xxl,
},
permissionWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
paddingHorizontal: spacing.xxl,
backgroundColor: c.background,
},
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+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,
+92 -73
View File
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { useCalendarStore } from '../../stores/calendar-store';
import { getEventColor } from '../../lib/calendar-utils';
import SenderAvatar from '../SenderAvatar';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const EMAIL_LIMIT = 5;
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const calendarEvents = useCalendarStore((s) => s.events);
const calendars = useCalendarStore((s) => s.calendars);
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
const addressKey = addresses.join(',');
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.wrap}>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MailIcon size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Recent emails</Text>
</View>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
<View style={styles.sectionIcon}>
<MailIcon size={16} color={c.textMuted} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
return (
<Pressable
key={email.id}
onPress={() =>
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
<View style={styles.rowContent}>
<View style={styles.rowTop}>
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
<Text style={styles.sectionLabel}>Recent emails</Text>
</View>
<View style={styles.sectionBody}>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
return (
<Pressable
key={email.id}
onPress={() =>
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
<View style={styles.rowContent}>
<View style={styles.rowTop}>
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
) : null}
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
) : null}
</View>
</Pressable>
);
})
)}
</Pressable>
);
})
)}
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<CalendarDays size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Upcoming events</Text>
<View style={styles.sectionIcon}>
<CalendarDays size={16} color={c.textMuted} />
</View>
<Text style={styles.sectionLabel}>Upcoming events</Text>
</View>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
</Text>
<View style={styles.sectionBody}>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<View
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
/>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
</Text>
</View>
</View>
</View>
))
)}
))
)}
</View>
</View>
</View>
);
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
section: {
backgroundColor: c.card,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
borderLeftWidth: 3,
borderLeftColor: c.primary,
wrap: { gap: spacing.lg },
section: { gap: spacing.sm },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: {
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
fontSize: 11,
letterSpacing: 0.6,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
sectionTitle: { ...typography.bodySemibold, color: c.text },
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: c.textMuted },
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
rowPreview: { ...typography.caption, color: c.textMuted },
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
eventDot: {
width: componentSizes.eventDot,
height: componentSizes.eventDot,
borderRadius: componentSizes.eventDot / 2,
alignSelf: 'center',
},
eventTime: {
...typography.caption,
color: c.textMuted,
width: 64,
width: 52,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: c.text },
+6 -1
View File
@@ -23,7 +23,12 @@ export default function TagAssignSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const keywords = useContactsStore(selectKeywordsUsed);
// Subscribe to the stable `contacts` array and derive the keyword list in a
// memo. Passing `selectKeywordsUsed` straight to the store would return a
// fresh array on every call, which trips useSyncExternalStore's snapshot
// check and spins into an infinite render loop.
const contacts = useContactsStore((s) => s.contacts);
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
const [input, setInput] = React.useState('');
+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 },
});
}
+8 -1
View File
@@ -22,7 +22,14 @@ export function ContactsSettings() {
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const books = useContactsStore(selectAddressBooksWithCount);
const addressBooks = useContactsStore((s) => s.addressBooks);
// Derive in a memo from stable store fields. Subscribing with
// `selectAddressBooksWithCount` directly returns a new array each call and
// sends useSyncExternalStore into an infinite render loop.
const books = React.useMemo(
() => selectAddressBooksWithCount(addressBooks, contacts),
[addressBooks, contacts],
);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
@@ -0,0 +1,173 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { RotateCcw } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Input from '../Input';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store';
import {
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
DEFAULT_EMAIL_TEMPLATE,
DEFAULT_ATTACHMENT_TEMPLATE,
type EmailFilenameOptions,
} from '../../lib/download-filename';
const SAMPLE = buildSampleEmail();
const SAMPLE_ATTACHMENT = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
export function DownloadsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailTemplate = useSettingsStore((s) => s.emailExportTemplate);
const attachmentTemplate = useSettingsStore((s) => s.attachmentExportTemplate);
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
const lowercase = useSettingsStore((s) => s.exportLowercase);
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const transforms: EmailFilenameOptions = { spaceReplacement, lowercase, stripDiacritics };
const emailPreview = emailExportFilename(SAMPLE, { ...transforms, template: emailTemplate });
const attachmentPreview = attachmentDownloadFilename(SAMPLE, SAMPLE_ATTACHMENT, {
...transforms,
template: attachmentTemplate,
});
return (
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title="Email Filename"
description="Template used when exporting a message as an .eml file."
>
<Input
value={emailTemplate}
onChangeText={(v) => update('emailExportTemplate', v)}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
onPress={() => update('emailExportTemplate', DEFAULT_EMAIL_TEMPLATE)}
style={styles.resetRow}
>
<RotateCcw size={12} color={c.mutedForeground} />
<Text style={styles.resetText}>Reset to default</Text>
</Pressable>
<View style={styles.previewBox}>
<Text style={styles.previewLabel}>Preview</Text>
<Text style={styles.previewValue}>{emailPreview}</Text>
</View>
<TokenList tokens={EMAIL_TOKENS} styles={styles} />
</SettingsSection>
<SettingsSection
title="Attachment Filename"
description="Template used when saving or sharing an attachment."
>
<Input
value={attachmentTemplate}
onChangeText={(v) => update('attachmentExportTemplate', v)}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
onPress={() => update('attachmentExportTemplate', DEFAULT_ATTACHMENT_TEMPLATE)}
style={styles.resetRow}
>
<RotateCcw size={12} color={c.mutedForeground} />
<Text style={styles.resetText}>Reset to default</Text>
</Pressable>
<View style={styles.previewBox}>
<Text style={styles.previewLabel}>Preview</Text>
<Text style={styles.previewValue}>{attachmentPreview}</Text>
</View>
<TokenList tokens={ATTACHMENT_TOKENS} styles={styles} />
</SettingsSection>
<SettingsSection
title="Filename Transform"
description="Applied to every exported filename."
>
<View style={styles.group}>
<SettingItem label="Spaces" description="Replace spaces in filenames." noBorder />
<RadioGroup
value={spaceReplacement}
onChange={(v) => update('exportSpaceReplacement', v as SpaceReplacement)}
options={[
{ value: 'keep', label: 'Keep' },
{ value: 'underscore', label: 'Underscore' },
{ value: 'dash', label: 'Dash' },
]}
/>
</View>
<SettingItem label="Lowercase" description="Force the whole filename to lowercase.">
<ToggleSwitch checked={lowercase} onChange={(v) => update('exportLowercase', v)} />
</SettingItem>
<SettingItem
label="Strip Accents"
description="Convert accented letters to plain ASCII (ä → a)."
>
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
</SettingItem>
</SettingsSection>
</View>
);
}
function TokenList({
tokens,
styles,
}: {
tokens: { token: string; description: string }[];
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.tokenWrap}>
{tokens.map((t) => (
<View key={t.token} style={styles.tokenPill}>
<Text style={styles.tokenText}>{`{${t.token}}`}</Text>
</View>
))}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: { gap: spacing.sm },
resetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.sm,
},
resetText: { ...typography.caption, color: c.mutedForeground },
previewBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
gap: 2,
},
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: c.muted,
},
tokenText: { fontSize: 11, color: c.mutedForeground },
});
}
+404 -124
View File
@@ -1,184 +1,464 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import {
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
ChevronUp, ChevronDown, Palmtree,
} from 'lucide-react-native';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useFilterStore } from '../../stores/filter-store';
import { useVacationStore } from '../../stores/vacation-store';
import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store';
import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types';
interface Rule {
id: string;
name: string;
enabled: boolean;
summary: string;
action: string;
type Translate = (key: string, fallback?: string) => string;
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === 'external' || r.origin === 'opaque';
}
const SAMPLE_RULES: Rule[] = [];
function summarizeRule(rule: FilterRule, t: Translate): string {
const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or');
const conditions = rule.conditions.slice(0, 2).map((cond) => {
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
return `${field} ${comparator} "${cond.value}"`;
});
const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : '';
const actions = rule.actions.slice(0, 2).map((a) => {
const action = t(`settings.filters.action_types.${a.type}`, a.type);
return a.value ? `${action} "${a.value}"` : action;
});
return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`;
}
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
const update = useSettingsStore((s) => s.updateSetting);
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const mailboxes = useEmailStore((s) => s.mailboxes);
const vacationEnabled = useVacationStore((s) => s.isEnabled);
const {
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings,
fetchFilters, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
setOpaqueScript, resetToVisualBuilder, validateScript,
} = useFilterStore();
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
useEffect(() => {
void fetchFilters();
}, [fetchFilters]);
const remove = (id: string) => {
setRules((rs) => rs.filter((r) => r.id !== id));
setDeleteId(null);
};
const showVacationBanner = (vacationEnabled || vacationSettings?.isEnabled) ?? false;
// Editable (Bulwark-managed) rules, in order, for reorder math.
const editableIds = useMemo(
() => rules.filter((r) => !isReadonlyRule(r)).map((r) => r.id),
[rules],
);
const persist = useCallback(async (rollback: () => void) => {
try {
await saveFilters();
} catch {
rollback();
Alert.alert(t('settings.filters.save_failed', 'Failed to save filters'));
}
}, [saveFilters, t]);
const handleToggle = useCallback((ruleId: string) => {
toggleRule(ruleId);
void persist(() => toggleRule(ruleId));
}, [toggleRule, persist]);
const handleDelete = useCallback((rule: FilterRule) => {
Alert.alert(
t('settings.filters.delete_rule', 'Delete Rule'),
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
[
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' },
{
text: t('settings.filters.confirm_delete', 'Delete'),
style: 'destructive',
onPress: () => {
deleteRule(rule.id);
void persist(() => addRule(rule));
},
},
],
);
}, [deleteRule, addRule, persist, t]);
const handleSaveRule = useCallback((rule: FilterRule) => {
const previous = useFilterStore.getState().rules;
if (editingRule) updateRule(rule.id, rule);
else addRule(rule);
setShowRuleModal(false);
setEditingRule(undefined);
void persist(() => useFilterStore.setState({ rules: previous }));
}, [editingRule, updateRule, addRule, persist]);
const handleSaveSieve = useCallback((content: string) => {
const previous = useFilterStore.getState();
setOpaqueScript(content);
setShowSieveEditor(false);
void persist(() => useFilterStore.setState({
isOpaque: previous.isOpaque,
rules: previous.rules,
rawScript: previous.rawScript,
}));
}, [setOpaqueScript, persist]);
const handleResetToVisual = useCallback(() => {
if (!showResetConfirm) {
setShowResetConfirm(true);
return;
}
const previous = useFilterStore.getState();
resetToVisualBuilder();
setShowResetConfirm(false);
void persist(() => useFilterStore.setState({
isOpaque: previous.isOpaque,
rules: previous.rules,
rawScript: previous.rawScript,
externalRequires: previous.externalRequires,
}));
}, [showResetConfirm, resetToVisualBuilder, persist]);
const moveRule = useCallback((ruleId: string, dir: -1 | 1) => {
const idx = editableIds.indexOf(ruleId);
const target = idx + dir;
if (idx === -1 || target < 0 || target >= editableIds.length) return;
const previousOrder = [...editableIds];
const next = [...editableIds];
[next[idx], next[target]] = [next[target], next[idx]];
reorderRules(next);
void persist(() => reorderRules(previousOrder));
}, [editableIds, reorderRules, persist]);
const handleValidate = useCallback(
(content: string) => validateScript(content),
[validateScript],
);
// ── Status screens ──────────────────────────────────────
if (!isSupported && !isLoading) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<Text style={styles.statusText}>{t('settings.filters.not_supported', 'Your mail server does not support email filters.')}</Text>
</SettingsSection>
);
}
if (isLoading) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<View style={styles.loadingRow}>
<ActivityIndicator size="small" color={c.mutedForeground} />
<Text style={styles.statusText}>{t('settings.filters.loading', 'Loading filters...')}</Text>
</View>
</SettingsSection>
);
}
if (error) {
return (
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
<Text style={[styles.statusText, { color: c.error }]}>{t('settings.filters.fetch_error', 'Failed to load filters')}</Text>
</SettingsSection>
);
}
return (
<View style={styles.container}>
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
<View style={{ gap: spacing.xl }}>
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
{isOpaque && (
<View style={styles.opaqueWarning}>
<AlertTriangle size={16} color="#fbbf24" />
<View style={styles.opaqueBanner}>
<AlertTriangle size={16} color={c.warning} />
<View style={{ flex: 1 }}>
<Text style={styles.opaqueText}>
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
{t('settings.filters.opaque_warning', 'This script was edited outside the visual builder. Only raw Sieve editing is available.')}
</Text>
<View style={styles.opaqueActions}>
<Pressable>
<Text style={styles.linkText}>Open Sieve Editor</Text>
</Pressable>
<Pressable style={styles.resetLink}>
<RotateCcw size={12} color={c.error} />
<Text style={styles.resetText}>Reset to visual</Text>
<Pressable onPress={() => setShowSieveEditor(true)}>
<Text style={styles.linkPrimary}>{t('settings.filters.open_sieve_editor', 'Open raw Sieve editor')}</Text>
</Pressable>
{showResetConfirm ? (
<View style={styles.resetConfirmRow}>
<Pressable onPress={handleResetToVisual}>
<Text style={styles.linkDanger}>{t('settings.filters.confirm_reset', 'Reset')}</Text>
</Pressable>
<Pressable onPress={() => setShowResetConfirm(false)}>
<Text style={styles.linkMuted}>{t('settings.filters.cancel', 'Cancel')}</Text>
</Pressable>
</View>
) : (
<Pressable onPress={handleResetToVisual} style={styles.resetLink}>
<RotateCcw size={12} color={c.error} />
<Text style={styles.linkDanger}>{t('settings.filters.reset_to_visual', 'Reset to visual builder')}</Text>
</Pressable>
)}
</View>
</View>
</View>
)}
{rules.length === 0 && !isOpaque && (
<View style={styles.emptyState}>
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No rules yet.</Text>
{!isOpaque && showVacationBanner && (
<View style={styles.vacationBanner}>
<View style={styles.vacationIcon}>
<Palmtree size={16} color={c.success} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.vacationTitle}>{t('settings.filters.vacation_active', 'Vacation Responder is active')}</Text>
<Text style={styles.vacationDesc}>{t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')}</Text>
</View>
</View>
)}
{rules.length > 0 && (
<View style={{ gap: 4 }}>
{rules.map((rule) => (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={styles.grip}>
<GripVertical size={16} color={c.mutedForeground} />
</View>
{!isOpaque && rules.length === 0 && !showVacationBanner && (
<View style={styles.emptyState}>
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>{t('settings.filters.no_rules', 'No filter rules')}</Text>
</View>
)}
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
{!isOpaque && rules.length > 0 && (
<View style={{ gap: spacing.sm }}>
{rules.map((rule) => {
const readonly = isReadonlyRule(rule);
const editIdx = editableIds.indexOf(rule.id);
<View style={{ flex: 1 }}>
<Text style={styles.ruleName}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
{rule.summary} → {rule.action}
</Text>
</View>
{deleteId === rule.id ? (
<View style={{ flexDirection: 'row', gap: 4 }}>
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
Confirm
</Button>
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
Cancel
</Button>
if (readonly) {
const label = rule.originLabel || t('settings.filters.origin_external', 'External');
const hasStructured = rule.origin === 'external' && rule.conditions.length > 0 && rule.actions.length > 0;
return (
<View key={rule.id} style={styles.ruleRow}>
<Lock size={16} color={c.mutedForeground} style={{ marginTop: 2 }} />
<View style={{ flex: 1, minWidth: 0 }}>
<View style={styles.ruleNameRow}>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
</View>
{hasStructured ? (
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
{summarizeRule(rule, t)}
</Text>
) : rule.rawBlock ? (
<Text style={styles.rawBlock} numberOfLines={expandedView ? undefined : 4}>
{rule.rawBlock.trim()}
</Text>
) : null}
</View>
</View>
) : (
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
);
}
return (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={{ paddingTop: 2 }}>
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} />
</View>
<Pressable
style={{ flex: 1, minWidth: 0 }}
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
{summarizeRule(rule, t)}
</Text>
</Pressable>
<View style={styles.reorderCol}>
<Pressable
onPress={() => moveRule(rule.id, -1)}
disabled={editIdx <= 0}
hitSlop={6}
style={[styles.reorderBtn, editIdx <= 0 && styles.reorderBtnDisabled]}
>
<ChevronUp size={16} color={c.mutedForeground} />
</Pressable>
<Pressable
onPress={() => moveRule(rule.id, 1)}
disabled={editIdx === editableIds.length - 1}
hitSlop={6}
style={[styles.reorderBtn, editIdx === editableIds.length - 1 && styles.reorderBtnDisabled]}
>
<ChevronDown size={16} color={c.mutedForeground} />
</Pressable>
</View>
<Pressable onPress={() => handleDelete(rule)} hitSlop={6} style={styles.deleteBtn}>
<X size={16} color={c.mutedForeground} />
</Pressable>
)}
</View>
))}
</View>
);
})}
</View>
)}
</SettingsSection>
<View style={styles.footer}>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<View style={styles.footerBtns}>
{!isOpaque && (
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
Add Rule
<Button
variant="outline"
size="sm"
icon={<Plus size={14} color={c.text} />}
onPress={() => { setEditingRule(undefined); setShowRuleModal(true); }}
>
{t('settings.filters.add_rule', 'Add Rule')}
</Button>
)}
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
Raw Editor
<Button
variant="outline"
size="sm"
icon={<Code size={14} color={c.text} />}
onPress={() => setShowSieveEditor(true)}
>
{t('settings.filters.raw_editor', 'Raw Sieve Editor')}
</Button>
</View>
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>Expanded view</Text>
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
</View>
)}
<View style={styles.footerRight}>
{isSaving && <ActivityIndicator size="small" color={c.mutedForeground} />}
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text>
<ToggleSwitch checked={expandedView} onChange={(v) => updateSetting('filtersExpandedView', v)} />
</View>
)}
</View>
</View>
<FilterRuleModal
visible={showRuleModal}
rule={editingRule}
mailboxes={mailboxes}
onSave={handleSaveRule}
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
/>
<SieveEditorSheet
visible={showSieveEditor}
content={rawScript}
onSave={handleSaveSieve}
onClose={() => setShowSieveEditor(false)}
onValidate={handleValidate}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
ruleDisabled: { opacity: 0.6 },
grip: { paddingTop: 2 },
ruleName: { ...typography.bodyMedium, color: c.text },
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: c.mutedForeground },
});
statusText: { ...typography.body, color: c.mutedForeground, paddingVertical: spacing.md },
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.md },
opaqueBanner: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
linkPrimary: { ...typography.caption, color: c.primary, fontWeight: '500' },
linkDanger: { ...typography.caption, color: c.error, fontWeight: '500' },
linkMuted: { ...typography.caption, color: c.mutedForeground },
vacationBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.success,
backgroundColor: c.successBg,
},
vacationIcon: {
width: 32, height: 32, borderRadius: radius.full,
alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(34,197,94,0.15)',
},
vacationTitle: { ...typography.bodyMedium, color: c.success },
vacationDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
},
ruleDisabled: { opacity: 0.6 },
ruleNameRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
ruleName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
rawBlock: {
...typography.caption,
color: c.mutedForeground,
fontFamily: 'monospace',
marginTop: spacing.sm,
padding: spacing.sm,
backgroundColor: c.muted,
borderRadius: radius.xs,
},
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
reorderCol: { alignItems: 'center' },
reorderBtn: { padding: 2 },
reorderBtnDisabled: { opacity: 0.25 },
deleteBtn: {
width: 28, height: 28,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
footerBtns: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
footerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: c.mutedForeground },
});
}
+82 -15
View File
@@ -1,10 +1,12 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore, type DateFormat, type TimeFormat } from '../../stores/settings-store';
import { formatListDate } from '../../lib/date-format';
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
export function LanguageSettings() {
@@ -12,15 +14,30 @@ export function LanguageSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const override = useLocaleStore((s) => s.override);
const setOverride = useLocaleStore((s) => s.setOverride);
const locale = useLocaleStore((s) => s.locale);
const hydrated = useLocaleStore((s) => s.hydrated);
const hydrate = useLocaleStore((s) => s.hydrate);
const t = useLocaleStore((s) => s.t);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const hydrateSettings = useSettingsStore((s) => s.hydrate);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const update = useSettingsStore((s) => s.updateSetting);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
useEffect(() => { if (!settingsHydrated) void hydrateSettings(); }, [settingsHydrated, hydrateSettings]);
const deviceCode = detectDeviceLocale();
const selected: LocaleCode | 'system' = override ?? 'system';
// Build live preview samples so the user sees what each format looks like.
const now = new Date();
const fmtOpts = { dateFormat, timeFormat, locale };
const previewToday = formatListDate(now, fmtOpts);
const previewWeek = formatListDate(new Date(now.getTime() - 3 * 86400000), fmtOpts);
const previewOlder = formatListDate(new Date(now.getTime() - 40 * 86400000), fmtOpts);
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
const active = selected === code;
return (
@@ -42,21 +59,61 @@ export function LanguageSettings() {
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
return (
<SettingsSection
title={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
>
<SettingItem
label={t('settings.appearance.language.label', 'Language')}
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
noBorder
/>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View>
</SettingsSection>
>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View>
</SettingsSection>
<SettingsSection
title={t('settings.language_region.date_format.label', 'Date Format')}
description={t('settings.language_region.date_format.description', 'How dates are shown in the email list')}
>
<RadioGroup
value={dateFormat}
onChange={(v) => update('dateFormat', v as DateFormat)}
options={[
{ value: 'smart', label: t('settings.language_region.date_format.smart', 'Smart (locale-aware)') },
{ value: 'relative', label: t('settings.language_region.date_format.relative', 'Relative (1h ago, 2d ago)') },
{ value: 'full', label: t('settings.language_region.date_format.full', 'Always full date') },
]}
/>
<View style={styles.previewBox}>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_today', 'Today:')}</Text>
<Text style={styles.previewValue}>{previewToday}</Text>
</View>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_this_week', 'This week:')}</Text>
<Text style={styles.previewValue}>{previewWeek}</Text>
</View>
<View style={styles.previewRow}>
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_older', 'Older:')}</Text>
<Text style={styles.previewValue}>{previewOlder}</Text>
</View>
</View>
</SettingsSection>
<SettingsSection
title={t('settings.language_region.time_format.label', 'Time Format')}
description={t('settings.language_region.time_format.description', 'Choose between 12-hour or 24-hour clock')}
>
<RadioGroup
value={timeFormat}
onChange={(v) => update('timeFormat', v as TimeFormat)}
options={[
{ value: '24h', label: t('settings.language_region.time_format.24h', '24-hour') },
{ value: '12h', label: t('settings.language_region.time_format.12h', '12-hour') },
]}
/>
</SettingsSection>
</View>
);
}
@@ -74,5 +131,15 @@ function makeStyles(c: ThemePalette) {
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
previewBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
gap: 4,
},
previewRow: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md },
previewLabel: { ...typography.caption, color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
});
}
+81 -3
View File
@@ -1,10 +1,18 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, RadioGroup } from './settings-section';
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
import { SettingsSection, RadioGroup, Select } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
ALL_QUICK_ACTIONS,
type SwipeAction,
type SwipeMode,
type QuickAction,
} from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
reply: 'Reply',
replyAll: 'Reply All',
forward: 'Forward',
delete: 'Delete',
archive: 'Archive',
markUnread: 'Mark Read/Unread',
star: 'Star',
move: 'Move to folder',
spam: 'Spam',
tag: 'Tag',
};
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
value,
label: QUICK_ACTION_LABELS[value],
}));
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -31,11 +57,27 @@ export function LayoutSettings() {
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const update = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
// Pick an action for a bar slot. If the chosen action already occupies
// another slot, swap the two so the three slots stay unique.
const setQuickActionSlot = (index: number, value: QuickAction) => {
const next = [...bottomActions];
const existing = next.indexOf(value);
if (existing !== -1 && existing !== index) {
next[existing] = next[index];
}
next[index] = value;
update('bottomQuickActions', next);
};
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
@@ -81,6 +123,34 @@ export function LayoutSettings() {
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<MoveVertical size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Reader quick actions</Text>
</View>
<Text style={styles.rowDescription}>
The three buttons shown between Prev/Next at the bottom of an open
email. Defaults to Reply, Reply All and Forward.
</Text>
{bottomActions.map((action, index) => (
<View key={index} style={styles.slotRow}>
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
<Select
value={action}
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
options={QUICK_ACTION_OPTIONS}
/>
</View>
))}
{relocated.length > 0 && (
<Text style={styles.rowDescription}>
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
you can still use {relocated.length === 1 ? 'it' : 'them'}.
</Text>
)}
</View>
</SettingsSection>
);
}
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
slotRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: spacing.xs,
},
slotLabel: { ...typography.body, color: c.text },
});
}
@@ -83,6 +83,7 @@ export function ReadingSettings() {
const showPreview = useSettingsStore((s) => s.showPreview);
const mailLayout = useSettingsStore((s) => s.mailLayout);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
@@ -177,6 +178,7 @@ export function ReadingSettings() {
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'trash-and-read', label: 'Move to Trash and mark as read' },
{ value: 'permanent', label: 'Permanently delete' },
]}
/>
@@ -250,6 +252,10 @@ export function ReadingSettings() {
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>
<SettingItem label="Include Group Inboxes" description="Also show group and shared inboxes in the unified All Inboxes view.">
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem>
+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');
});
});
+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;
}
+45 -4
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,8 +88,11 @@ export async function shareAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
email?: Email | null,
): 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);
@@ -84,8 +115,11 @@ export async function downloadAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
email?: Email | null,
): 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);
@@ -111,8 +145,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 }];
}
+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));
}
+84
View File
@@ -123,6 +123,90 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// QR login payloads. A QR scanned on the login screen either bootstraps the
// server URL for the normal webmail handoff (`connect`), or carries a one-time
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
// The payload never contains credentials — the `pair` code is redeemed for
// tokens over the network, once.
export type QrLoginPayload =
| { kind: 'connect'; webmailUrl: string }
| { kind: 'pair'; webmailUrl: string; code: string };
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
const trimmed = raw.trim();
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
if (match) {
const kind = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const server = params.get('server');
if (!server || !/^https?:\/\//i.test(server)) return null;
if (kind === 'pair') {
const code = params.get('code');
if (!code) return null;
return { kind: 'pair', webmailUrl: server, code };
}
return { kind: 'connect', webmailUrl: server };
}
// A bare https URL is treated as a server-bootstrap target so admins can
// hand out a plain webmail URL QR without the custom-scheme wrapper.
if (/^https?:\/\//i.test(trimmed)) {
return { kind: 'connect', webmailUrl: trimmed };
}
return null;
}
// Cross-device pairing redemption. Posts the scanned code to the webmail that
// minted it and maps the token bundle into the same shape the in-browser OAuth
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
const base = webmailUrl.replace(/\/+$/, '');
let response: Response;
try {
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ pairing_code: code }),
});
} catch {
throw new HandoffError('Could not reach the server to complete pairing');
}
if (!response.ok) {
// The code was unknown, already used, or expired (server returns 400) —
// or something else went wrong. Either way the user needs a fresh QR.
throw new HandoffError('Pairing code is invalid or has expired');
}
const data = (await response.json()) as {
server_url?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
token_endpoint?: string;
client_id?: string;
};
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
throw new HandoffError('Pairing response missing token material');
}
return {
flow: 'oauth',
serverUrl: data.server_url,
tokens: {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? undefined,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: data.token_endpoint,
clientId: data.client_id,
},
};
}
// 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.
+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,
@@ -0,0 +1,381 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '../types';
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'bw-1',
name: 'Bulwark Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('external rule preservation (issue #201)', () => {
describe('parser - external rule recognition', () => {
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
const rule = result.rules[0];
expect(rule.origin).toBe('external');
expect(rule.originLabel).toBe('Roundcube');
expect(rule.name).toBe('Archive Newsletters');
expect(rule.conditions[0]).toMatchObject({
field: 'header',
comparator: 'contains',
value: 'news',
headerName: 'List-Id',
});
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
});
it('labels rules near a Nextcloud marker comment', () => {
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('Nextcloud');
});
it('falls back to "External" label when no known marker is present', () => {
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('External');
});
it('parses anyof/allof conditions in external rules', () => {
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].matchType).toBe('any');
expect(result.rules[0].conditions).toHaveLength(2);
});
it('parses negated conditions (not header :is)', () => {
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.rules[0].conditions[0]).toMatchObject({
field: 'from',
comparator: 'not_is',
value: 'spam@x.com',
});
});
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
const rule = result.rules[0];
expect(rule.origin).toBe('opaque');
expect(rule.rawBlock).toContain('if header :value');
});
it('collects all external require tokens', () => {
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
const result = parseScript(script);
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
});
});
describe('parser - mixed Bulwark + external', () => {
it('returns Bulwark rules from metadata and external rules from the rest', () => {
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
const bulwarkScript = generateScript(bulwark);
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
const result = parseScript(mixedScript);
expect(result.isOpaque).toBe(false);
expect(result.rules.length).toBeGreaterThanOrEqual(2);
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
const externalParsed = result.rules.filter(r => r.origin === 'external');
expect(bulwarkParsed).toHaveLength(1);
expect(bulwarkParsed[0].name).toBe('Bulwark A');
expect(externalParsed).toHaveLength(1);
expect(externalParsed[0].originLabel).toBe('Nextcloud');
});
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
const script = generateScript(bulwark);
const result = parseScript(script);
// Only the metadata-sourced rule, no duplicate "external" entry.
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
});
it('does not duplicate a Bulwark rule whose values contain literal braces', () => {
// Regression for an issue where a value like "Report Domain: {x}" caused
// parseIfBlockToRule's naive indexOf('{') to point inside the value
// instead of at the body, so the if-block fell back to an opaque rule
// that escaped the bulwark-name dedup filter.
const bulwark = [
makeBulwarkRule({
name: 'DMARC reports',
matchType: 'any',
conditions: [
{ field: 'subject', comparator: 'contains', value: 'Report Domain: mydomain.com' },
{ field: 'subject', comparator: 'contains', value: 'Report Domain: {mydomain.com}' },
],
actions: [{ type: 'mark_read' }, { type: 'move', value: 'DMARC' }],
}),
];
const script = generateScript(bulwark);
const result = parseScript(script);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
expect(result.rules[0].name).toBe('DMARC reports');
});
});
describe('generator - external splice', () => {
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
const externalRule: FilterRule = {
id: 'ext-0',
name: 'External',
enabled: true,
matchType: 'all',
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
actions: [{ type: 'move', value: 'Lists' }],
stopProcessing: false,
origin: 'external',
originLabel: 'Nextcloud',
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
};
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
const script = generateScript(rules);
expect(script).toContain('# Rule: Bulwark Rule');
expect(script).toContain('# Nextcloud Mail');
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
const externalIdx = script.indexOf('# Nextcloud Mail');
expect(bulwarkIdx).toBeLessThan(externalIdx);
});
it('unions external requires into the top-level require line', () => {
const script = generateScript([makeBulwarkRule()], undefined, {
externalRequires: ['fileinto', 'imap4flags', 'body'],
});
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"imap4flags"');
expect(requireLine).toContain('"body"');
});
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
const bulwarkWithJunk: FilterRule = {
...makeBulwarkRule(),
origin: 'bulwark',
originLabel: 'shouldnotbehere',
rawBlock: 'shouldnotbehere',
};
const script = generateScript([bulwarkWithJunk]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0]).not.toHaveProperty('origin');
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
});
it('never writes external rules into metadata', () => {
const ext: FilterRule = {
id: 'ext-0',
name: 'Ext',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
actions: [{ type: 'keep' }],
stopProcessing: false,
origin: 'external',
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
};
const script = generateScript([makeBulwarkRule(), ext]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].name).toBe('Bulwark Rule');
});
});
describe('fixture: mixed-origins.sieve', () => {
const fixture = readFileSync(
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
'utf-8',
);
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
const result = parseScript(fixture);
expect(result.isOpaque).toBe(false);
expect(result.vacation).toBeUndefined();
const byOrigin = {
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
external: result.rules.filter(r => r.origin === 'external'),
opaque: result.rules.filter(r => r.origin === 'opaque'),
};
expect(byOrigin.bulwark).toHaveLength(2);
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
expect(byOrigin.opaque).toHaveLength(1);
const labels = byOrigin.external.map(r => r.originLabel);
expect(labels).toContain('Roundcube');
expect(labels).toContain('Nextcloud');
expect(labels).toContain('External');
const opaqueRule = byOrigin.opaque[0];
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
});
it('preserves unknown-Sieve content through save round-trip', () => {
const parsed = parseScript(fixture);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// The unparseable construct must appear verbatim in the regenerated script.
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
// Require tokens from the external content are preserved.
expect(regenerated).toContain('"relational"');
// Bulwark rules are still present.
expect(regenerated).toContain('# Rule: Archive newsletters');
});
});
describe('round-trip', () => {
it('preserves external rules through parse → generate → parse', () => {
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const firstParse = parseScript(initial);
expect(firstParse.rules).toHaveLength(2);
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
externalRequires: firstParse.externalRequires,
});
const secondParse = parseScript(regenerated);
expect(secondParse.rules).toHaveLength(2);
const names = secondParse.rules.map(r => r.name).sort();
expect(names).toContain('VIP');
});
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
const parsed = parseScript(initial);
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
expect(externalBefore).toHaveLength(1);
// Simulate a user edit - update the Bulwark rule name
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
const reparsed = parseScript(regenerated);
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
expect(externalAfter).toHaveLength(1);
expect(externalAfter[0].name).toBe('Untouchable');
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
describe('Nextcloud Mail marker regions', () => {
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const nextcloudScript = [
NEXTCLOUD_MARKER,
'require ["imap4flags", "fileinto"];',
NEXTCLOUD_MARKER,
'',
NEXTCLOUD_MARKER,
'# FILTER: [{"name":"Wetterwarnungen"}]',
'if header :contains "From" "weather@x.com" {',
' fileinto "Weather";',
'}',
'',
'# PayPal',
'if header :contains "From" "paypal.de" {',
' addflag "$paypal";',
'}',
NEXTCLOUD_MARKER,
'',
].join('\n');
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
const result = parseScript(nextcloudScript);
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
expect(nextcloud).toHaveLength(1);
expect(nextcloud[0].origin).toBe('opaque');
// Every marker that wrapped actual rule content survives in the rawBlock.
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
expect(markerCount).toBe(2);
// Inner if-blocks are not exposed as separate external rules.
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
const result = parseScript(nextcloudScript);
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
});
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
const script = [
NEXTCLOUD_MARKER,
'require ["fileinto"];',
NEXTCLOUD_MARKER,
'',
].join('\n');
const result = parseScript(script);
expect(result.rules).toHaveLength(0);
expect(result.externalRequires).toContain('fileinto');
});
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
const parsed = parseScript(initial);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// Both wrapping markers remain, in the right positions.
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
expect(firstMarker).toBeGreaterThanOrEqual(0);
expect(lastMarker).toBeGreaterThan(firstMarker);
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
expect(regenerated).toContain('# PayPal');
const reparsed = parseScript(regenerated);
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('does not emit duplicate "External rules" headers across repeated saves', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
let script = initial;
for (let i = 0; i < 3; i++) {
const parsed = parseScript(script);
script = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
}
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
expect(headerCount).toBe(1);
});
});
});
@@ -0,0 +1,44 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
@metadata:end */
require ["body", "copy", "fileinto", "imap4flags", "relational"];
# Rule: Archive newsletters
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
fileinto "Newsletters";
}
# Rule: Flag VIP senders
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
addflag "\\Flagged";
addflag "\\Seen";
}
# --- External rules (managed outside Bulwark) ---
# rule:[Finance - auto-file invoices]
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
fileinto :copy "Finance/Invoices";
keep;
}
# Nextcloud Mail - begin
# Filter installed by Nextcloud Mail app
if header :contains "Subject" "[Support]" {
fileinto "Support";
}
# Nextcloud Mail - end
# A handwritten rule without a tool-specific marker.
# Bulwark should recognize this as generic "External" and preserve it.
if not header :is "X-Spam-Status" "No" {
fileinto "Junk";
}
# A rule using a Sieve construct Bulwark's visual editor does not understand.
# It must survive round-trips verbatim, shown to the user as read-only.
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
fileinto "LowPriority";
stop;
}
+470
View File
@@ -0,0 +1,470 @@
import { describe, it, expect } from 'vitest';
import { generateScript } from '../generator';
import { parseScript } from '../parser';
import type { FilterRule } from '../types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('generateScript', () => {
it('outputs metadata and no require for empty rules', () => {
const script = generateScript([]);
expect(script).toContain('/* @metadata:begin');
expect(script).toContain('@metadata:end */');
expect(script).not.toContain('require');
});
it('embeds compact metadata JSON', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
expect(match).not.toBeNull();
const metadata = JSON.parse(match![1]);
expect(metadata.version).toBe(1);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].id).toBe('rule-1');
});
it('generates single rule with from/contains', () => {
const script = generateScript([makeRule()]);
expect(script).toContain('# Rule: Test Rule');
expect(script).toContain('if header :contains "From" "test@example.com"');
expect(script).toContain('fileinto "Archive";');
});
describe('condition fields', () => {
it('maps to field to "To" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
})]);
expect(script).toContain('header :contains "To" "me@x.com"');
});
it('maps cc field to "Cc" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
})]);
expect(script).toContain('header :is "Cc" "cc@x.com"');
});
it('maps subject field to "Subject" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
})]);
expect(script).toContain('header :contains "Subject" "hello"');
});
it('maps header field with custom headerName', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
})]);
expect(script).toContain('header :contains "X-Custom" "test"');
});
it('handles size greater_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
})]);
expect(script).toContain('size :over 1000000');
});
it('handles size less_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
})]);
expect(script).toContain('size :under 500');
});
it('handles body contains', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
})]);
expect(script).toContain('body :contains "keyword"');
expect(script).toContain('"body"');
});
it('handles body is', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
})]);
expect(script).toContain('body :is "exact"');
});
});
describe('comparators', () => {
it('generates not_contains with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
})]);
expect(script).toContain('not header :contains "From" "spam"');
});
it('generates not_is with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
})]);
expect(script).toContain('not header :is "From" "bad@x.com"');
});
it('generates starts_with as :matches with trailing *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
})]);
expect(script).toContain('header :matches "Subject" "Re:*"');
});
it('generates ends_with as :matches with leading *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
})]);
expect(script).toContain('header :matches "Subject" "*urgent"');
});
it('generates matches as :matches', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
})]);
expect(script).toContain('header :matches "From" "*@company.com"');
});
});
describe('match types', () => {
it('wraps multiple conditions with allof for matchType all', () => {
const script = generateScript([makeRule({
matchType: 'all',
conditions: [
{ field: 'from', comparator: 'contains', value: 'a' },
{ field: 'subject', comparator: 'contains', value: 'b' },
],
})]);
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
});
it('wraps multiple conditions with anyof for matchType any', () => {
const script = generateScript([makeRule({
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'x' },
{ field: 'to', comparator: 'contains', value: 'y' },
],
})]);
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
});
it('uses no wrapper for single condition', () => {
const script = generateScript([makeRule()]);
expect(script).not.toContain('allof');
expect(script).not.toContain('anyof');
});
});
describe('actions', () => {
it('generates move as fileinto', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
expect(script).toContain('fileinto "Spam";');
});
it('generates copy as fileinto :copy', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
expect(script).toContain('fileinto :copy "Backup";');
});
it('generates forward as redirect', () => {
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
expect(script).toContain('redirect "fwd@x.com";');
});
it('generates mark_read as addflag \\Seen', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('addflag "\\\\Seen";');
});
it('generates star as addflag \\Flagged', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $label:Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$label:Important";');
});
it('generates discard', () => {
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
expect(script).toContain('discard;');
});
it('generates reject with message', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
expect(script).toContain('reject "Go away";');
});
it('generates keep', () => {
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
expect(script).toContain('keep;');
});
it('generates stop', () => {
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
expect(script).toContain('stop;');
});
});
describe('stopProcessing', () => {
it('appends stop when stopProcessing is true', () => {
const script = generateScript([makeRule({ stopProcessing: true })]);
const ifBlock = script.slice(script.indexOf('if '));
expect(ifBlock).toContain('stop;');
});
it('does not duplicate stop if last action is stop', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toHaveLength(1);
});
it('does not append stop after discard', () => {
const script = generateScript([makeRule({
actions: [{ type: 'discard' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
it('does not append stop after reject', () => {
const script = generateScript([makeRule({
actions: [{ type: 'reject', value: 'No' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
});
describe('disabled rules', () => {
it('excludes disabled rules from Sieve code', () => {
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
expect(script).not.toContain('# Rule: Hidden');
expect(script).not.toContain('if header');
});
it('preserves disabled rules in metadata', () => {
const rules = [makeRule({ enabled: false })];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0].enabled).toBe(false);
});
it('handles mixed enabled and disabled rules', () => {
const rules = [
makeRule({ id: '1', name: 'Active', enabled: true }),
makeRule({ id: '2', name: 'Inactive', enabled: false }),
makeRule({ id: '3', name: 'Also Active', enabled: true }),
];
const script = generateScript(rules);
expect(script).toContain('# Rule: Active');
expect(script).not.toContain('# Rule: Inactive');
expect(script).toContain('# Rule: Also Active');
});
});
describe('require extensions', () => {
it('includes fileinto for move', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
expect(script).toContain('"fileinto"');
});
it('includes fileinto and copy for copy action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
expect(script).toContain('"copy"');
expect(script).toContain('"fileinto"');
});
it('includes imap4flags for mark_read', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for star', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for add_label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes reject for reject action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
expect(script).toContain('"reject"');
});
it('includes body extension for body conditions', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
})]);
expect(script).toContain('"body"');
});
it('deduplicates extensions', () => {
const script = generateScript([
makeRule({ id: '1', actions: [{ type: 'star' }] }),
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
]);
const matches = script.match(/"imap4flags"/g);
expect(matches).toHaveLength(1);
});
it('only considers enabled rules for requires', () => {
const script = generateScript([
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
]);
const requireLine = script.split('\n').find(l => l.startsWith('require'));
expect(requireLine).not.toContain('reject');
expect(requireLine).toContain('fileinto');
});
});
describe('escaping', () => {
it('escapes double quotes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
})]);
expect(script).toContain('say \\"hello\\"');
});
it('escapes backslashes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
})]);
expect(script).toContain('path\\\\to\\\\file');
});
it('escapes folder names in actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'My "Folder"' }],
})]);
expect(script).toContain('fileinto "My \\"Folder\\"";');
});
});
describe('round-trip', () => {
it('preserves rules through generate → parse cycle', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Rule A', enabled: true }),
makeRule({ id: '2', name: 'Rule B', enabled: false }),
makeRule({
id: '3',
name: 'Complex',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'boss' },
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
stopProcessing: true,
}),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
});
it('generates multiple rules in order', () => {
const rules = [
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
];
const script = generateScript(rules);
const firstIdx = script.indexOf('# Rule: First');
const secondIdx = script.indexOf('# Rule: Second');
expect(firstIdx).toBeLessThan(secondIdx);
});
describe('edge cases', () => {
it('skips rules with empty conditions', () => {
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
expect(script).not.toContain('# Rule: Empty');
expect(script).not.toContain('if ');
});
it('skips rules with empty actions', () => {
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
expect(script).not.toContain('# Rule: NoAction');
expect(script).not.toContain('if ');
});
it('uses X-Unknown for header field without headerName', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
})]);
expect(script).toContain('header :contains "X-Unknown" "test"');
});
it('handles all enabled rules with different extensions combined', () => {
const rules = [
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
];
const script = generateScript(rules);
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"reject"');
expect(requireLine).toContain('"body"');
expect(requireLine).toContain('"imap4flags"');
});
it('generates no require line when only keep/discard/stop/forward actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
})]);
expect(script).not.toContain('require');
});
it('handles multiple actions on same rule', () => {
const script = generateScript([makeRule({
actions: [
{ type: 'move', value: 'Folder' },
{ type: 'mark_read' },
{ type: 'star' },
{ type: 'stop' },
],
})]);
expect(script).toContain('fileinto "Folder";');
expect(script).toContain('addflag "\\\\Seen";');
expect(script).toContain('addflag "\\\\Flagged";');
expect(script).toContain('stop;');
});
it('generates valid script for all-disabled rules', () => {
const rules = [
makeRule({ id: '1', enabled: false }),
makeRule({ id: '2', enabled: false }),
];
const script = generateScript(rules);
expect(script).toContain('@metadata:begin');
expect(script).not.toContain('if ');
expect(script).not.toContain('require');
});
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '../types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('parseScript', () => {
it('extracts rules from valid metadata', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('parses external rules when no Bulwark metadata is present', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBe('external');
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
expect(result.externalRequires).toContain('fileinto');
});
it('returns isOpaque for corrupted JSON', () => {
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for version mismatch', () => {
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for empty metadata block', () => {
const script = '/* @metadata:begin\n\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for missing rules array', () => {
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for invalid rule objects', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('handles metadata with extra whitespace', () => {
const rules = [makeRule()];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('handles script with only metadata block', () => {
const rules = [makeRule({ enabled: false })];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing end marker', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
});
it('treats an empty string as an empty, editable script (not opaque)', () => {
const result = parseScript('');
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
describe('round-trip', () => {
it('preserves complex rules through generate → parse', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
makeRule({
id: '2',
name: 'VIP',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
}),
makeRule({ id: '3', name: 'Disabled', enabled: false }),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('preserves rules with special characters', () => {
const rules = [makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
actions: [{ type: 'move', value: 'My "Folder"' }],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('preserves all action types', () => {
const rules = [makeRule({
actions: [
{ type: 'move', value: 'Folder' },
{ type: 'copy', value: 'Backup' },
{ type: 'forward', value: 'fwd@x.com' },
{ type: 'mark_read' },
{ type: 'star' },
{ type: 'add_label', value: 'Tag' },
{ type: 'keep' },
],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
});
describe('validation edge cases', () => {
it('returns isOpaque when rule has non-string id', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when rule has non-boolean enabled', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when rule has invalid matchType', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when condition missing value', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('returns isOpaque when action missing type', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
expect(parseScript(script).isOpaque).toBe(true);
});
it('accepts valid empty rules array', () => {
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
it('preserves all comparator types through round-trip', () => {
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
const rules = comparators.map((comparator, i) => makeRule({
id: `r${i}`,
name: `Rule ${comparator}`,
conditions: [{ field: 'from', comparator, value: 'test' }],
}));
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('preserves size comparators through round-trip', () => {
const rules = [
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('preserves header field with custom headerName', () => {
const rules = [makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import type {
FilterRule,
FilterCondition,
FilterAction,
FilterMetadata,
VacationSieveConfig,
} from './types';
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
// script with a leading `@metadata` JSON comment that lets the parser round-trip
// the visual rules losslessly. Keep this in sync with the web generator so a
// script saved on one client reads back cleanly on the other.
const HEADER_MAP: Record<string, string> = {
from: 'From',
to: 'To',
cc: 'Cc',
subject: 'Subject',
};
function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
if (field === 'size') {
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`;
}
if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`;
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field];
const escaped = escapeString(value);
switch (comparator) {
case 'contains':
return `header :contains "${headerName}" "${escaped}"`;
case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`;
case 'is':
return `header :is "${headerName}" "${escaped}"`;
case 'not_is':
return `not header :is "${headerName}" "${escaped}"`;
case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`;
case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`;
case 'matches':
return `header :matches "${headerName}" "${escaped}"`;
default:
return `header :contains "${headerName}" "${escaped}"`;
}
}
function generateActions(actions: FilterAction[]): string[] {
return actions.map(action => {
switch (action.type) {
case 'move':
return `fileinto "${escapeString(action.value || '')}";`;
case 'copy':
return `fileinto :copy "${escapeString(action.value || '')}";`;
case 'forward':
return `redirect "${escapeString(action.value || '')}";`;
case 'mark_read':
return 'addflag "\\\\Seen";';
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
return `reject "${escapeString(action.value || '')}";`;
case 'keep':
return 'keep;';
case 'stop':
return 'stop;';
}
});
}
function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] {
const extensions = new Set<string>();
const enabledRules = rules.filter(r => r.enabled);
if (vacation?.isEnabled) {
extensions.add('vacation');
}
for (const rule of enabledRules) {
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
}
for (const action of rule.actions) {
switch (action.type) {
case 'move':
extensions.add('fileinto');
break;
case 'copy':
extensions.add('fileinto');
extensions.add('copy');
break;
case 'mark_read':
case 'star':
case 'add_label':
extensions.add('imap4flags');
break;
case 'reject':
extensions.add('reject');
break;
}
}
}
return [...extensions];
}
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
matchType: r.matchType,
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
};
}
export interface GenerateOptions {
/**
* Require extensions used by external (non-Bulwark) rules that we must
* preserve in the top-level `require` directive. Duplicates with Bulwark's
* own requires are deduplicated.
*/
externalRequires?: string[];
}
export function generateScript(
rules: FilterRule[],
vacation?: VacationSieveConfig,
options: GenerateOptions = {},
): string {
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
const bulwarkRules: FilterRule[] = [];
const externalRules: FilterRule[] = [];
for (const r of rules) {
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
else bulwarkRules.push(r);
}
const metadata: FilterMetadata = {
version: 1,
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
};
if (vacation?.isEnabled) {
metadata.vacation = vacation;
}
const metadataJson = JSON.stringify(metadata);
const lines: string[] = [];
lines.push('/* @metadata:begin');
lines.push(metadataJson);
lines.push('@metadata:end */');
lines.push('');
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
if (allRequires.length > 0) {
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
}
if (vacation?.isEnabled) {
lines.push('');
lines.push('# Vacation auto-reply');
const vacationParts: string[] = [];
if (vacation.subject) {
vacationParts.push(`:subject "${escapeString(vacation.subject)}"`);
}
vacationParts.push(`"${escapeString(vacation.textBody || '')}"`);
lines.push(`vacation ${vacationParts.join(' ')};`);
}
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledBulwarkRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
// Empty conditions/actions can't form a valid `if`; the rule modal
// prevents this, but skip defensively rather than emit broken Sieve.
continue;
}
lines.push('');
lines.push(`# Rule: ${rule.name}`);
const conditions = rule.conditions.map(generateCondition);
let conditionStr: string;
if (conditions.length === 0) {
conditionStr = 'true';
} else if (conditions.length === 1) {
conditionStr = conditions[0];
} else {
const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof';
conditionStr = `${wrapper}(${conditions.join(', ')})`;
}
const actionLines = generateActions(rule.actions);
if (rule.stopProcessing) {
const lastAction = rule.actions[rule.actions.length - 1];
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
actionLines.push('stop;');
}
}
lines.push(`if ${conditionStr} {`);
for (const actionLine of actionLines) {
lines.push(` ${actionLine}`);
}
lines.push('}');
}
// Append preserved external rules verbatim. Each rawBlock already carries its
// own leading comments and trailing whitespace from the source script.
if (externalRules.length > 0) {
lines.push('');
lines.push('# --- External rules (managed outside Bulwark) ---');
for (const ext of externalRules) {
if (!ext.rawBlock) continue;
lines.push(ext.rawBlock.replace(/\s+$/, ''));
}
}
lines.push('');
return lines.join('\n');
}
+759
View File
@@ -0,0 +1,759 @@
import type {
FilterAction,
FilterCondition,
FilterComparator,
FilterConditionField,
FilterMetadata,
FilterRule,
VacationSieveConfig,
} from './types';
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
// are best-effort parsed into read-only "external" rules or preserved opaquely.
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
vacation?: VacationSieveConfig;
externalRequires: string[];
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const BULWARK_EXTERNAL_HEADER_RE =
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
cc: 'cc',
subject: 'subject',
};
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
}
function isValidAction(a: unknown): boolean {
if (!a || typeof a !== 'object') return false;
const act = a as Record<string, unknown>;
return typeof act.type === 'string';
}
function isValidRule(rule: unknown): rule is FilterRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>;
if (
typeof r.id !== 'string' ||
typeof r.name !== 'string' ||
typeof r.enabled !== 'boolean' ||
(r.matchType !== 'all' && r.matchType !== 'any') ||
!Array.isArray(r.conditions) ||
!Array.isArray(r.actions) ||
typeof r.stopProcessing !== 'boolean'
) return false;
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
}
/**
* Detect Stalwart-generated vacation-only scripts (no metadata).
*/
function detectVacationOnlyScript(content: string): ParseResult | null {
if (!/\bvacation\b/.test(content)) return null;
const stripped = content
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
if (!/\bvacation\b/.test(structural)) return null;
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
let textBody = '';
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
if (mimeBodyMatch) {
textBody = mimeBodyMatch[1].trim();
} else {
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
const last = allQuoted[allQuoted.length - 1];
if (last) textBody = unescapeSieveString(last[1]);
}
return {
rules: [],
isOpaque: false,
vacation: { isEnabled: true, subject, textBody },
externalRequires: [],
};
}
function unescapeSieveString(s: string): string {
return s.replace(/\\(.)/g, '$1');
}
function skipStringLit(s: string, i: number): number {
i++;
while (i < s.length) {
if (s[i] === '\\') { i += 2; continue; }
if (s[i] === '"') return i + 1;
i++;
}
return i;
}
function skipHashComment(s: string, i: number): number {
while (i < s.length && s[i] !== '\n') i++;
return i;
}
function skipBlockComment(s: string, i: number): number {
const end = s.indexOf('*/', i + 2);
return end === -1 ? s.length : end + 2;
}
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === ';') return i + 1;
i++;
}
return i;
}
function skipBalanced(s: string, i: number, open: string, close: string): number {
let depth = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === open) { depth++; i++; continue; }
if (c === close) {
depth--;
i++;
if (depth === 0) return i;
continue;
}
i++;
}
return i;
}
function skipIfStatement(s: string, i: number): number {
// positioned after 'if' keyword; skip through condition expression and body braces
while (i < s.length && s[i] !== '{') {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
i++;
}
if (i >= s.length) return i;
return skipBalanced(s, i, '{', '}');
}
interface TopBlock {
kind: 'require' | 'if' | 'vacation' | 'other';
raw: string; // from start-of-leading-text to end of statement
statement: string; // the statement itself (no leading comments/whitespace)
startIdx: number;
endIdx: number;
}
function scanTopLevel(content: string): TopBlock[] {
const blocks: TopBlock[] = [];
let i = 0;
let segmentStart = 0;
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
blocks.push({
kind,
raw: content.slice(segmentStart, stmtEnd),
statement: content.slice(stmtStart, stmtEnd),
startIdx: segmentStart,
endIdx: stmtEnd,
});
segmentStart = stmtEnd;
};
while (i < content.length) {
// Skip whitespace
while (i < content.length && /\s/.test(content[i])) i++;
if (i >= content.length) break;
const c = content[i];
// Comments (stay attached to next block as leading text)
if (c === '#') { i = skipHashComment(content, i); continue; }
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
// Identifier
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
if (!m) { i++; continue; }
const ident = m[0];
const stmtStart = i;
i += ident.length;
if (ident === 'require') {
i = skipStatement(content, i);
consume('require', stmtStart, i);
} else if (ident === 'if') {
i = skipIfStatement(content, i);
consume('if', stmtStart, i);
} else if (ident === 'vacation') {
i = skipStatement(content, i);
consume('vacation', stmtStart, i);
} else {
i = skipStatement(content, i);
consume('other', stmtStart, i);
}
}
return blocks;
}
function extractRequireTokens(stmt: string): string[] {
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
return mSingle ? [mSingle[1]] : [];
}
/**
* Extract the last contiguous block of comments immediately preceding a
* statement - comments separated from the statement by a blank line are not
* considered its leading commentary (they likely belong to the previous
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
*/
function lastCommentChunk(leading: string): string {
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
function detectOriginLabel(leading: string): string {
const chunk = lastCommentChunk(leading);
const lower = chunk.toLowerCase();
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
if (/nextcloud/.test(lower)) return 'Nextcloud';
if (/horde|ingo/.test(lower)) return 'Horde';
if (/kolab/.test(lower)) return 'Kolab';
if (/dovecot/.test(lower)) return 'Dovecot';
if (/thunderbird/.test(lower)) return 'Thunderbird';
return 'External';
}
function extractName(leading: string, fallback: string): string {
// Roundcube: "# rule:[Name]"
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
if (rc) return rc[1].trim();
// "# Rule: Name"
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
if (rr) return rr[1].trim();
// Last non-empty trimmed comment line
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
return fallback;
}
function splitTopLevelComma(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
if (c === ',' && depth === 0) {
parts.push(s.slice(start, i));
start = i + 1;
}
i++;
}
parts.push(s.slice(start));
return parts.map(p => p.trim()).filter(Boolean);
}
function splitStatements(body: string): string[] {
const stmts: string[] = [];
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '"') { i = skipStringLit(body, i); continue; }
if (c === '#') { i = skipHashComment(body, i); continue; }
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
if (c === ';') {
stmts.push(body.slice(start, i));
start = i + 1;
}
i++;
}
const tail = body.slice(start).trim();
if (tail) stmts.push(tail);
return stmts.map(s => s.trim()).filter(Boolean);
}
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
const lc = name.toLowerCase();
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
return { field: 'header', headerName: name };
}
function parseAtom(raw: string): FilterCondition | null {
let s = raw.trim();
let negated = false;
if (/^not\b/.test(s)) {
negated = true;
s = s.replace(/^not\s*/, '').trim();
if (s.startsWith('(') && s.endsWith(')')) {
s = s.slice(1, -1).trim();
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
} else {
// :matches - distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
}
const cond: FilterCondition = { field, comparator, value };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
if (m) {
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
}
return null;
}
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
const s = raw.trim();
if (!s) return null;
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
let matchType: 'all' | 'any' = 'all';
let inner: string;
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
else inner = s;
const parts = splitTopLevelComma(inner);
const conditions: FilterCondition[] = [];
for (const part of parts) {
const atom = parseAtom(part);
if (!atom) return null;
conditions.push(atom);
}
return { matchType, conditions };
}
function parseAction(raw: string): FilterAction | null {
const s = raw.trim();
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const flag = unescapeSieveString(m[1]);
if (flag === '\\Seen') return { type: 'mark_read' };
if (flag === '\\Flagged') return { type: 'star' };
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
return null;
}
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
if (/^discard$/.test(s)) return { type: 'discard' };
if (/^keep$/.test(s)) return { type: 'keep' };
if (/^stop$/.test(s)) return { type: 'stop' };
return null;
}
function findBodyOpenBrace(s: string): number {
// Locate the first `{` that introduces the if-block body, skipping over
// string literals and comments. A naive indexOf('{') would otherwise pick
// up braces inside condition values (e.g. `:contains "{foo}"`).
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === '{') return i;
i++;
}
return -1;
}
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
const stmt = block.statement;
const afterIf = stmt.replace(/^if\s+/, '');
const braceIdx = findBodyOpenBrace(afterIf);
// scanTopLevel's skipIfStatement uses balanced-brace scanning, so the
// statement always terminates at the matching `}`.
const lastBraceIdx = afterIf.length - 1;
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
const condStr = afterIf.slice(0, braceIdx).trim();
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
const cond = parseCondition(condStr);
if (!cond || cond.conditions.length === 0) return null;
const actionStmts = splitStatements(bodyStr);
const actions: FilterAction[] = [];
for (const st of actionStmts) {
const a = parseAction(st);
if (!a) return null;
actions.push(a);
}
if (actions.length === 0) return null;
let stopProcessing = false;
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
const hasNonStop = actions.some(a => a.type !== 'stop');
if (hasNonStop) {
stopProcessing = true;
actions.pop();
}
}
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `Rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: cond.matchType,
conditions: cond.conditions,
actions,
stopProcessing,
origin: 'external',
originLabel,
rawBlock: block.raw,
};
}
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `External rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel,
rawBlock: block.raw,
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Nextcloud Mail wraps its managed filter region with a pair of
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
* emits two such regions - one enclosing its own `require [...]` line and
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
* JSON comments. Parsing the interior blocks individually loses the outer
* markers (causing later rules to fall back to "External") and mis-attaches
* them to neighboring blocks on round-trip.
*
* Treat each marker-pair as one opaque external rule so the whole region
* round-trips verbatim, merge any require tokens into the top-level list,
* and drop regions that carry nothing but a require (their extensions are
* already represented in the merged require line).
*/
function extractNextcloudRegions(content: string): {
cleaned: string;
rules: FilterRule[];
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
const positions: number[] = [];
let searchFrom = 0;
while (true) {
const idx = content.indexOf(marker, searchFrom);
if (idx === -1) break;
const atLineStart = idx === 0 || content[idx - 1] === '\n';
if (atLineStart) positions.push(idx);
searchFrom = idx + marker.length;
}
if (positions.length < 2) {
return { cleaned: content, rules: [], requires: [] };
}
const rules: FilterRule[] = [];
const requires: string[] = [];
const markerRe = new RegExp(escapeRegex(marker), 'g');
let cleaned = '';
let cursor = 0;
for (let i = 0; i + 1 < positions.length; i += 2) {
const start = positions[i];
const closeStart = positions[i + 1];
const lineEnd = content.indexOf('\n', closeStart + marker.length);
const end = lineEnd === -1 ? content.length : lineEnd + 1;
cleaned += content.slice(cursor, start);
const raw = content.slice(start, end);
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
if (!requires.includes(tok[1])) requires.push(tok[1]);
}
}
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
const stripped = raw
.replace(markerRe, '')
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
.replace(/require\s+"[^"]+"\s*;/g, '')
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
if (stripped.length > 0) {
rules.push({
id: `nextcloud-${rules.length}`,
name: 'Nextcloud Mail filters',
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel: 'Nextcloud',
rawBlock: raw,
});
}
cursor = end;
}
cleaned += content.slice(cursor);
return { cleaned, rules, requires };
}
function stripBulwarkExternalHeader(content: string): string {
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
}
function parseExternalRules(
content: string,
idPrefix: string,
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
const blocks = scanTopLevel(content);
const rules: FilterRule[] = [];
const externalRequires: string[] = [];
let index = 0;
let sawAnyStatement = false;
for (const block of blocks) {
if (block.kind === 'require') {
sawAnyStatement = true;
for (const tok of extractRequireTokens(block.statement)) {
if (!externalRequires.includes(tok)) externalRequires.push(tok);
}
continue;
}
if (block.kind === 'if') {
sawAnyStatement = true;
const rule = parseIfBlockToRule(block, idPrefix, index);
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
index++;
continue;
}
// vacation/other: treat as opaque preserved block
sawAnyStatement = true;
rules.push(makeOpaqueRule(block, idPrefix, index));
index++;
}
return { rules, externalRequires, hasContent: sawAnyStatement };
}
export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx !== -1) {
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
console.warn('[filters] Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
// it so it does not get re-attached to the first external rule's rawBlock
// and written out twice on the next save.
const afterMetadata = stripBulwarkExternalHeader(
content.slice(endIdx + METADATA_END.length),
);
const nextcloud = extractNextcloudRegions(afterMetadata);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
const externalRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
// This applies regardless of whether the block parsed as a structured rule or
// fell back to opaque - a Bulwark-emitted block may fail to round-trip cleanly
// (e.g. a value with literal braces) but the `# Rule: <name>` marker still
// identifies it as ours.
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
if (match) {
const name = match[1].trim();
if (bulwarkRules.some(b => b.name === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;
});
return {
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
};
}
// No metadata - check vacation-only first
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
// Extract Nextcloud-managed marker regions first so their interior is not
// parsed as a series of loose if-blocks (which would lose the outer markers
// and mis-label later blocks as generic "External").
const nextcloud = extractNextcloudRegions(content);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
const allRules = [...nextcloud.rules, ...external.rules];
const allRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
if (!external.hasContent && allRules.length === 0) {
// Entirely empty or whitespace/comments only - treat as empty, editable.
// Preserve any require tokens lifted out of Nextcloud marker regions so
// they can be re-emitted in the top-level require line.
return { rules: [], isOpaque: false, externalRequires: allRequires };
}
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || allRules.length > 0) {
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
}
return OPAQUE;
}
+72
View File
@@ -0,0 +1,72 @@
// Sieve filter data model. Ported from the webmail's lib/jmap/sieve-types.ts so
// the generated scripts round-trip byte-for-byte between the two clients (a
// rule authored on the web reads back as a structured rule on mobile and vice
// versa, because both embed the same `@metadata` JSON block).
export interface SieveScript {
id: string;
name: string;
blobId: string;
isActive: boolean;
}
export interface SieveCapabilities {
implementation: string;
maxSizeScript: number;
sieveExtensions: string[];
notificationMethods: string[];
externalLists: string[];
}
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
export type FilterComparator =
| 'contains' | 'not_contains'
| 'is' | 'not_is'
| 'starts_with' | 'ends_with'
| 'matches'
| 'greater_than' | 'less_than';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
| 'mark_read' | 'star' | 'add_label'
| 'discard' | 'reject' | 'keep' | 'stop';
export interface FilterCondition {
field: FilterConditionField;
comparator: FilterComparator;
value: string;
headerName?: string;
}
export interface FilterAction {
type: FilterActionType;
value?: string;
}
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
export interface FilterRule {
id: string;
name: string;
enabled: boolean;
matchType: 'all' | 'any';
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
}
export interface VacationSieveConfig {
isEnabled: boolean;
subject: string;
textBody: string;
}
export interface FilterMetadata {
version: 1;
rules: FilterRule[];
vacation?: VacationSieveConfig;
}
+95
View File
@@ -0,0 +1,95 @@
// TOTP enrolment helper. Mirrors the webmail's use of `otpauth` (Settings →
// Security → Two-Factor) but without pulling in a native crypto dependency:
// we only need to mint a fresh shared secret and the `otpauth://` enrolment
// URL. The 6-digit code the user types is verified server-side by Stalwart
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
// device — that keeps this file dependency-free.
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
const SECRET_BYTES = 20;
function randomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
const c = (globalThis as {
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
}).crypto;
// Best: a real CSPRNG (web / a polyfilled RN).
if (c?.getRandomValues) {
try {
c.getRandomValues(bytes);
return bytes;
} catch {
// fall through
}
}
// Good enough: derive bytes from crypto.randomUUID, which Hermes exposes
// even when getRandomValues isn't polyfilled (~122 bits of entropy each).
if (c?.randomUUID) {
try {
let hex = '';
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
} catch {
// fall through
}
}
// Last resort: Math.random. Same trade-off the rest of the app accepts in
// lib/uuid.ts and lib/oauth.ts; the secret is one-off and server-validated.
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
return bytes;
}
// RFC 4648 base32, no padding — the form authenticator apps expect in the
// `secret` query parameter.
function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = '';
for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
export interface TotpEnrolment {
// Base32 secret, shown to the user for manual entry.
secret: string;
// Same secret grouped in fours for easier manual typing.
secretFormatted: string;
// otpauth:// URL — feeds the "Add to authenticator app" deep link and the
// value sent to the server as otpUrl.
url: string;
}
export function generateTotpEnrolment(accountLabel: string): TotpEnrolment {
const secret = base32Encode(randomBytes(SECRET_BYTES));
const issuer = 'Stalwart';
const label = accountLabel || 'account';
const params = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: '6',
period: '30',
});
// Label is conventionally "Issuer:account"; both label path and issuer param
// are encoded so authenticators that read either render a sensible name.
const url = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?${params.toString()}`;
const secretFormatted = secret.replace(/(.{4})/g, '$1 ').trim();
return { secret, secretFormatted, url };
}
+2 -1
View File
@@ -2,7 +2,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string };
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
Compose:
| {
@@ -13,6 +13,7 @@ export type RootStackParamList = {
cc?: EmailAddress[];
subject: string;
body?: string;
receivedAt?: string;
inReplyTo?: string;
references?: string;
};
+5 -1
View File
@@ -212,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
@@ -317,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);
+69 -19
View File
@@ -1,11 +1,12 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as DocumentPicker from 'expo-document-picker';
import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag,
Archive, FolderInput, Tag, Import,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -21,6 +22,8 @@ import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
@@ -43,20 +46,6 @@ function isPinned(email: Email): boolean {
return !!email.keywords?.$important;
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
const diffMin = Math.floor(diffMs / (1000 * 60));
const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay === 1) return 'Yesterday';
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const EmailRow = React.memo(function EmailRow({
item,
@@ -79,6 +68,10 @@ const EmailRow = React.memo(function EmailRow({
const styles = React.useMemo(() => makeStyles(c), [c]);
const dyn = useTypography();
const density = useDensity();
// Read the date-rendering prefs here so each row re-renders when they change.
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const locale = useLocaleStore((s) => s.locale);
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -133,7 +126,7 @@ const EmailRow = React.memo(function EmailRow({
<Text style={styles.threadBadgeText}>{threadCount}</Text>
</View>
)}
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
<Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
</View>
</View>
@@ -184,6 +177,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const selectMailbox = useEmailStore((s) => s.selectMailbox);
const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails);
const refreshEmails = useEmailStore((s) => s.refreshEmails);
const importEmails = useEmailStore((s) => s.importEmails);
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
const setFilters = useEmailStore((s) => s.setFilters);
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
@@ -250,6 +244,50 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
[mailboxes],
);
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
const [importing, setImporting] = React.useState(false);
const handleImport = React.useCallback(async () => {
const targetMailboxId =
currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null;
if (!targetMailboxId || importing) return;
let result: DocumentPicker.DocumentPickerResult;
try {
result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
type: [
'message/rfc822',
'application/zip',
'application/x-zip-compressed',
'application/octet-stream',
],
});
} catch {
return; // picker dismissed / unavailable
}
if (result.canceled) return;
if (result.assets.length === 0) return;
setImporting(true);
try {
const files = result.assets.map((a) => ({
uri: a.uri,
name: a.name,
mimeType: a.mimeType,
}));
const { imported, failed } = await importEmails(files, targetMailboxId);
Alert.alert(
'Import',
failed > 0
? `${imported} imported, ${failed} failed.`
: imported === 0
? 'No messages were imported.'
: `${imported} message${imported === 1 ? '' : 's'} imported.`,
);
} finally {
setImporting(false);
}
}, [currentMailboxId, mailboxes, importEmails, importing]);
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
// Batch (multi-select) sheets.
@@ -555,8 +593,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
style={styles.headerButton}
disabled={importing}
hitSlop={6}
>
{importing ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Import size={20} color={c.textMuted} />
)}
</Pressable>
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
@@ -1025,7 +1075,7 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
headerLogo: {
width: 28,
height: 28,
+1 -1
View File
@@ -34,7 +34,7 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
const onShare = async () => {
try {
await shareEmailEml(blobId, subject);
await shareEmailEml(blobId, undefined, subject);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
+473 -174
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert,
Modal, useWindowDimensions, Animated, Easing, Alert, PanResponder,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -18,7 +18,12 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
type QuickAction,
} from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
@@ -54,7 +59,11 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { emailId, subject: subjectParam } = route.params;
const { jmapAccountId } = route.params;
// The displayed email is tracked in local state (not a route param) so that
// swiping / Prev-Next can switch messages in place without remounting the
// screen — which is what produced the loading flash on every change.
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
@@ -66,40 +75,143 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject ?? '',
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
// In-memory cache of fetched message details keyed by id. This is the single
// source of truth for every rendered pane: the active message *and* its
// prefetched neighbours all read their detail from here, so a swipe lands on
// ready content with no spinner and no late content-swap. `cacheVersion` is
// bumped whenever an entry changes so the panes re-render.
const detailCache = React.useRef(new Map<string, Email>()).current;
const [cacheVersion, setCacheVersion] = React.useState(0);
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
const email = React.useMemo(
() => detailCache.get(activeEmailId) ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmailId, cacheVersion, detailCache],
);
// --- Swipe / slide navigation between adjacent emails -------------------
// A three-pane pager: [prev | current | next] are laid out in a single row of
// width 3×screen and kept mounted side by side. `drag` translates the whole
// row so the neighbour's real content follows the finger — no blank gap and
// no late content-swap. At rest the row is shifted left by one screen so the
// middle (current) pane sits on screen. After a settled swipe we re-point
// `activeEmailId` at the neighbour and reset `drag` to 0 in the same commit;
// because the landed pane already shows that exact message, the recentre is
// seamless. Refs keep the (memoised once) PanResponder reading the latest
// neighbours / dimensions without being recreated every render.
const drag = React.useRef(new Animated.Value(0)).current;
const animatingRef = React.useRef(false);
const prevEmailRef = React.useRef(prevEmail);
const nextEmailRef = React.useRef(nextEmail);
const windowWidthRef = React.useRef(windowWidth);
prevEmailRef.current = prevEmail;
nextEmailRef.current = nextEmail;
windowWidthRef.current = windowWidth;
const switchEmail = React.useCallback((direction: 'prev' | 'next') => {
const target = direction === 'next' ? nextEmailRef.current : prevEmailRef.current;
if (!target || animatingRef.current) return;
animatingRef.current = true;
const w = windowWidthRef.current;
// Bring the neighbour pane to centre: next sits to the right (slide row
// left → -w), prev sits to the left (slide row right → +w).
const out = direction === 'next' ? -w : w;
// Best-effort: ensure the target detail is cached so the recentred pane has
// content. Neighbours are normally prefetched, so this is usually a no-op.
if (!detailCache.has(target.id)) {
getEmailDetail(target.id, jmapAccountId)
.then((e) => { detailCache.set(target.id, e); bumpCache(); })
.catch(() => { /* the load effect will retry once it becomes active */ });
}
Animated.timing(drag, {
toValue: out, duration: 230, easing: Easing.out(Easing.cubic), useNativeDriver: true,
}).start(({ finished }) => {
if (!finished) { animatingRef.current = false; return; }
// Re-point at the neighbour and recentre in one go. The pane that just
// slid to centre keeps its mounted WebView (it becomes the new middle
// pane, keyed by id), so there is nothing to reload or flash.
setActiveEmailId(target.id);
drag.setValue(0);
animatingRef.current = false;
});
}, [drag, detailCache, getEmailDetail, jmapAccountId, bumpCache]);
const switchEmailRef = React.useRef(switchEmail);
switchEmailRef.current = switchEmail;
const panResponder = React.useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, g) => {
if (animatingRef.current) return false;
if (Math.abs(g.dx) < 10) return false;
// Only claim clearly-horizontal drags so the body's vertical scroll
// (and link taps) keep working.
return Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
},
onPanResponderMove: (_, g) => {
let dx = g.dx;
// Rubber-band when there's no neighbour to reach in that direction.
if ((dx > 0 && !prevEmailRef.current) || (dx < 0 && !nextEmailRef.current)) {
dx *= 0.25;
}
drag.setValue(dx);
},
onPanResponderRelease: (_, g) => {
const w = windowWidthRef.current;
const threshold = Math.min(w * 0.28, 120);
const fast = Math.abs(g.vx) > 0.5;
if ((g.dx <= -threshold || (fast && g.vx < 0)) && nextEmailRef.current) {
switchEmailRef.current('next');
} else if ((g.dx >= threshold || (fast && g.vx > 0)) && prevEmailRef.current) {
switchEmailRef.current('prev');
} else {
Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
}
},
onPanResponderTerminate: () => {
Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
},
onPanResponderTerminationRequest: () => true,
}),
).current;
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const bottomActions = React.useMemo(
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
[bottomQuickActionsRaw],
);
// Any reply-family action the user pulled out of the bottom bar is surfaced
// in the top toolbar so it stays reachable.
const relocatedActions = React.useMemo(
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
[bottomActions],
);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
async (target: Email, blobId: string, name: string | undefined, type: string | undefined) => {
if (downloadingBlobId) return;
setDownloadingBlobId(blobId);
try {
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type);
await downloadAttachment(blobId, name, type, target);
} else {
await shareAttachment(blobId, name, type);
await shareAttachment(blobId, name, type, target);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
@@ -109,64 +221,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
},
[downloadingBlobId, mailAttachmentAction],
);
const renderAttachments = () => {
const all = email?.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -175,24 +229,36 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
setError(null);
// If we already have this message cached (e.g. it was prefetched as a
// neighbour), show it immediately — no spinner — and refresh in the
// background. Otherwise fall back to the loading state.
const cached = detailCache.get(activeEmailId);
if (cached) {
setLoading(false);
setError(null);
} else {
setLoading(true);
setError(null);
}
void (async () => {
try {
const fetched = await getEmailDetail(emailId);
const fetched = await getEmailDetail(activeEmailId, jmapAccountId);
if (cancelled) return;
setEmail(fetched);
detailCache.set(activeEmailId, fetched);
bumpCache();
if (fetched && !fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId);
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(emailId);
void markRead(activeEmailId, jmapAccountId);
}
}
} catch (e) {
if (!cancelled) {
// Only surface the error if we have nothing to show; a cached copy is
// better than an error screen when the refresh fails.
if (!cancelled && !detailCache.has(activeEmailId)) {
setError(e instanceof Error ? e.message : 'Failed to load email');
}
} finally {
@@ -203,7 +269,20 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache, bumpCache]);
// Prefetch the adjacent messages so their pane is already populated when the
// pager slides it into view. Best-effort and silent; bump the cache version
// on arrival so the off-screen neighbour pane renders its content.
React.useEffect(() => {
for (const neighbour of [prevEmail, nextEmail]) {
if (neighbour && !detailCache.has(neighbour.id)) {
getEmailDetail(neighbour.id, jmapAccountId)
.then((e) => { detailCache.set(neighbour.id, e); bumpCache(); })
.catch(() => { /* ignore — prefetch is best-effort */ });
}
}
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache, bumpCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -222,8 +301,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
const updateLocalKeywords = (id: string, next: Record<string, boolean>) => {
const prev = detailCache.get(id);
if (!prev) return;
detailCache.set(id, { ...prev, keywords: next });
bumpCache();
};
const onToggleKeyword = (token: string) => {
@@ -231,28 +315,32 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const next = { ...email.keywords };
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
};
const onToggleStar = () => {
if (!email) return;
const next = { ...email.keywords };
if (starred) delete next.$flagged;
// Toggle the star on a specific message — used both by the toolbar (current
// message) and by each pane's own subject star.
const toggleStarFor = React.useCallback((target: Email) => {
const next = { ...target.keywords };
if (next.$flagged) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const prev = detailCache.get(target.id);
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
void setEmailKeywords(target.id, next);
}, [detailCache, bumpCache]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
updateLocalKeywords({ ...email.keywords, $seen: true });
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
}
};
@@ -306,13 +394,84 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cc: email.cc,
subject: email.subject ?? '',
body: plainTextBody(email),
receivedAt: email.receivedAt,
inReplyTo: email.id,
},
});
};
const subject = email?.subject ?? subjectParam ?? '(no subject)';
const from = email?.from?.[0];
// Registry of every action that can live in the bottom quick-action bar (or
// be relocated to the top toolbar). `icon` is a factory so the same action
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
const quickActionRegistry: Record<
QuickAction,
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
> = {
reply: {
label: 'Reply',
icon: (s, col) => <Reply size={s} color={col} />,
onPress: () => navigateCompose('reply'),
available: true,
},
replyAll: {
label: 'Reply All',
icon: (s, col) => <ReplyAll size={s} color={col} />,
onPress: () => navigateCompose('replyAll'),
available: true,
},
forward: {
label: 'Forward',
icon: (s, col) => <Forward size={s} color={col} />,
onPress: () => navigateCompose('forward'),
available: true,
},
delete: {
label: 'Delete',
icon: (s, col) => <Trash2 size={s} color={col} />,
onPress: onDelete,
available: true,
},
archive: {
label: 'Archive',
icon: (s, col) => <Archive size={s} color={col} />,
onPress: onArchive,
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
},
markUnread: {
label: unread ? 'Read' : 'Unread',
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
onPress: onToggleUnread,
available: true,
},
star: {
label: starred ? 'Unstar' : 'Star',
icon: (s, col) => (
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
),
onPress: onToggleStar,
available: true,
},
move: {
label: 'Move',
icon: (s, col) => <FolderInput size={s} color={col} />,
onPress: () => setMoveMenuOpen(true),
available: mailboxes.length > 0,
},
spam: {
label: isInJunk ? 'Not spam' : 'Spam',
icon: (s, col) =>
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
onPress: onToggleSpam,
available: !!junkMailbox || isInJunk,
},
tag: {
label: 'Tag',
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
},
};
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
@@ -328,6 +487,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
{relocatedActions.map((id) => {
const def = quickActionRegistry[id];
return (
<ToolbarButton
key={id}
icon={def.icon(18, c.textSecondary)}
label={def.label}
onPress={def.onPress}
/>
);
})}
<ToolbarButton
icon={<Trash2 size={18} color={c.textSecondary} />}
label="Delete"
@@ -372,105 +542,71 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</View>
{loading ? (
{loading && !email ? (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
) : error && !email ? (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : email ? (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<View style={styles.subjectMain}>
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
{/* Three-pane pager: [prev | current | next] live side by side in a
row shifted left by one screen, so a horizontal drag slides the
neighbour's already-rendered content into view with no gap. */}
<View style={styles.pagerViewport}>
<Animated.View
style={[
styles.pagerRow,
{ width: windowWidth * 3, left: -windowWidth, transform: [{ translateX: drag }] },
]}
{...panResponder.panHandlers}
>
{[prevEmail?.id ?? null, activeEmailId, nextEmail?.id ?? null].map((id, i) => (
<View key={id ?? `empty-${i}`} style={{ width: windowWidth }}>
{id ? (
<EmailPane
email={detailCache.get(id) ?? null}
c={c}
styles={styles}
bottomBarHeight={bottomBarHeight}
attachmentPosition={attachmentPosition}
hideInlineImageAttachments={hideInlineImageAttachments}
downloadingBlobId={downloadingBlobId}
onToggleStar={toggleStarFor}
onPressAttachment={onPressAttachment}
/>
</Pressable>
<Text style={styles.subjectText}>{subject}</Text>
</View>
<View style={styles.subjectMeta}>
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
{email.size > 0 && (
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
)}
</View>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
</View>
</ScrollView>
))}
</Animated.View>
</View>
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label="Prev"
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
onPress={prevEmail ? () => switchEmail('prev') : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={c.textSecondary} />}
label="Reply"
onPress={() => navigateCompose('reply')}
/>
<BottomBarButton
icon={<ReplyAll size={20} color={c.textSecondary} />}
label="Reply All"
onPress={() => navigateCompose('replyAll')}
/>
<BottomBarButton
icon={<Forward size={20} color={c.textSecondary} />}
label="Forward"
onPress={() => navigateCompose('forward')}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
onPress={nextEmail ? () => switchEmail('next') : undefined}
disabled={!nextEmail}
/>
</View>
@@ -508,7 +644,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email.subject);
await shareEmailEml(email.blobId, email);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
@@ -534,6 +670,166 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
}
interface EmailPaneProps {
email: Email | null;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
attachmentPosition: 'beside-sender' | 'below-header';
hideInlineImageAttachments: boolean;
downloadingBlobId: string | null;
onToggleStar: (email: Email) => void;
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
}
// One swipeable page: the scrollable subject / sender / attachments / body for a
// single message. The pager keeps three of these mounted (prev, current, next)
// so a swipe slides ready content into view. Each pane owns its own vertical
// scroll position and "show all attachments" toggle, and renders directly from
// the email passed to it — neighbours show real content, not a placeholder.
function EmailPane({
email, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment,
}: EmailPaneProps) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
// A neighbour whose detail hasn't been prefetched yet. Brief and uncommon
// (the prefetch effect usually beats the swipe), so a spinner is fine.
if (!email) {
return (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
);
}
const from = email.from?.[0];
const starred = !!email.keywords?.$flagged;
const subject = email.subject || '(no subject)';
const renderAttachments = () => {
const all = email.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(email, att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<View style={styles.subjectMain}>
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
<Text style={styles.subjectText}>{subject}</Text>
</View>
<View style={styles.subjectMeta}>
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
{email.size > 0 && (
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
)}
</View>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
</View>
</ScrollView>
);
}
interface MoreMenuSheetProps {
visible: boolean;
onClose: () => void;
@@ -855,6 +1151,9 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
// Pager: a clipping viewport with a 3-screen-wide row of panes inside it.
pagerViewport: { flex: 1, overflow: 'hidden', backgroundColor: c.background },
pagerRow: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row' },
scroll: { flex: 1, backgroundColor: c.background },
// Subject block
+51 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [scannerVisible, setScannerVisible] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
@@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleScanned = async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
return;
}
try {
const before = useAuthStore.getState().isAuthenticated;
if (payload.kind === 'connect') {
// Server-bootstrap QR: fill the field and run the normal browser
// handoff so the user still authenticates in the webmail.
setServerUrl(payload.webmailUrl);
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
} else {
// Cross-device pairing QR: redeem the one-time code for tokens, no
// browser round-trip needed.
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
}
const after = useAuthStore.getState().isAuthenticated;
if ((after && !before) || (isAddMode && after)) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
</View>
</View>
</KeyboardAvoidingView>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
/>
</SafeAreaView>
);
}
+5 -2
View File
@@ -6,7 +6,7 @@ import {
Palette, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug, Download,
type LucideIcon,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -35,6 +35,7 @@ import { ContentSendersSettings } from '../components/settings/ContentSendersSet
import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
@@ -42,7 +43,7 @@ type Tab =
| 'account' | 'language' | 'notifications'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords'
| 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads'
| 'security' | 'encryption' | 'content_senders'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
@@ -88,6 +89,7 @@ const TABS: TabDef[] = [
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
{ id: 'downloads', label: 'Downloads', icon: Download, group: 'mail', implemented: true },
// Privacy & Security
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
@@ -122,6 +124,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
templates: TemplateSettings,
folders: FolderSettings,
keywords: KeywordSettings,
downloads: DownloadsSettings,
security: AccountSecuritySettings,
encryption: SmimeSettings,
content_senders: ContentSendersSettings,
+28 -14
View File
@@ -10,6 +10,9 @@ import SenderAvatar from '../components/SenderAvatar';
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -19,22 +22,15 @@ function senderName(email: UnifiedEmail): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
function formatRelativeTime(date: Date): string {
const diffMin = Math.floor((Date.now() - date.getTime()) / 60000);
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}d`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function UnifiedInboxScreen({ navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accounts = useAccountStore((s) => s.accounts);
const switchAccount = useAuthStore((s) => s.switchAccount);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
const locale = useLocaleStore((s) => s.locale);
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
@@ -50,13 +46,13 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
const load = React.useCallback(async () => {
setLoading(true);
try {
const result = await fetchUnifiedInbox(accountIds);
const result = await fetchUnifiedInbox(accountIds, 25, { includeGroup });
setEmails(result.emails);
setErrors(result.errors);
} finally {
setLoading(false);
}
}, [accountIds]);
}, [accountIds, includeGroup]);
React.useEffect(() => {
void load();
@@ -77,6 +73,9 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
// Group/shared messages live under another JMAP account in the
// same session; pass it so the thread opens against the right one.
jmapAccountId: email.isShared ? email.jmapAccountId : undefined,
});
} finally {
setOpening(false);
@@ -112,7 +111,7 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
@@ -123,6 +122,13 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
{acc.email || acc.username}
</Text>
)}
{item.isShared && (
<View style={styles.sharedBadge}>
<Text style={styles.sharedBadgeText} numberOfLines={1}>
{item.sharedLabel || 'Shared'}
</Text>
</View>
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
@@ -229,6 +235,14 @@ function makeStyles(c: ThemePalette) {
time: { ...typography.caption, color: c.textMuted },
subject: { ...typography.body, color: c.text },
account: { ...typography.caption, color: c.textMuted },
sharedBadge: {
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: radius.full,
backgroundColor: c.muted,
maxWidth: 120,
},
sharedBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
preview: { ...typography.caption, color: c.textMuted },
});
}
+37
View File
@@ -14,13 +14,48 @@ vi.mock('../../api/email', () => ({
getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
setKeywordsForEmails: vi.fn(),
moveEmail: vi.fn(),
moveEmails: vi.fn(),
archiveEmails: vi.fn(),
restoreEmailMailboxes: vi.fn(),
setEmailMailboxes: vi.fn(),
destroyEmails: vi.fn(),
deleteEmail: vi.fn(),
deleteEmails: vi.fn(),
searchEmails: vi.fn(),
}));
// The mutations now route through the offline outbox. In tests we want the
// "online, nothing queued" fast path: run the supplied online runner (or the
// op's primitive) immediately so the existing api-call assertions still hold,
// without pulling in network-store / NetInfo.
vi.mock('../outbox-store', async () => {
const api = await import('../../api/email') as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId]);
};
const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise<void>) => {
if (onlineRun) await onlineRun();
else await Promise.all(ops.map(runOp));
return { queued: false };
};
return {
applyOrQueueBatch,
applyOrQueue: async (op: any, onlineRun?: () => Promise<void>) => applyOrQueueBatch([op], onlineRun),
useOutboxStore: {
getState: () => ({
entries: [],
count: () => 0,
setAccount: vi.fn(async () => undefined),
flush: vi.fn(async () => undefined),
}),
},
};
});
// settings-store transitively pulls in jmap-client / expo-secure-store, which
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
@@ -44,6 +79,8 @@ vi.mock('../offline-cache-store', () => ({
has: () => false,
get: vi.fn(async () => null),
put: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
}),
},
}));
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// NetInfo is a native module; stub it so network-store loads under node. The
// store defaults to online, which we flip per-test via setState.
vi.mock('@react-native-community/netinfo', () => ({
default: {
addEventListener: () => () => undefined,
fetch: async () => ({ isConnected: true, isInternetReachable: true }),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { isConnected: true },
}));
const setEmailKeywords = vi.fn(async (..._a: any[]) => undefined);
const setEmailMailboxes = vi.fn(async (..._a: any[]) => undefined);
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
vi.mock('../../api/email', () => ({
setEmailKeywords: (...a: any[]) => setEmailKeywords(...a),
setEmailMailboxes: (...a: any[]) => setEmailMailboxes(...a),
destroyEmails: (...a: any[]) => destroyEmails(...a),
}));
import { useOutboxStore, applyOrQueue } from '../outbox-store';
import { useNetworkStore } from '../network-store';
const ACCOUNT = 'acc-1';
beforeEach(async () => {
vi.clearAllMocks();
useNetworkStore.setState({ online: true, connected: true });
// Detach then attach a clean bucket (AsyncStorage is the in-memory mock).
await useOutboxStore.getState().setAccount(null);
await useOutboxStore.getState().clear();
await useOutboxStore.getState().setAccount(ACCOUNT);
await useOutboxStore.getState().clear();
});
describe('outbox enqueue + coalescing', () => {
it('coalesces repeated keyword ops for the same email (last-write-wins)', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1);
expect(entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
});
it('keeps keyword and mailbox ops for the same email separate', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
expect(useOutboxStore.getState().entries).toHaveLength(2);
});
it('destroy supersedes pending edits for that email', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
store.enqueue({ kind: 'destroy', emailId: 'e1' });
const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1);
expect(entries[0].op.kind).toBe('destroy');
});
it('ignores further edits once a destroy is queued', () => {
const store = useOutboxStore.getState();
store.enqueue({ kind: 'destroy', emailId: 'e1' });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].op.kind).toBe('destroy');
});
});
describe('applyOrQueue', () => {
it('runs immediately when online with an empty queue', async () => {
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(false);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('prefers the supplied online runner over the primitive', async () => {
const onlineRun = vi.fn(async () => undefined);
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } }, onlineRun);
expect(onlineRun).toHaveBeenCalledOnce();
expect(setEmailMailboxes).not.toHaveBeenCalled();
});
it('queues instead of running when offline', async () => {
useNetworkStore.setState({ online: false });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
it('queues a later op for the same email to preserve order', async () => {
useNetworkStore.setState({ online: false });
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } });
useNetworkStore.setState({ online: true });
// Now online, but an op is already queued for e1 → must queue, not run.
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled();
});
it('surfaces a non-transient (server) error to the caller', async () => {
const onlineRun = vi.fn(async () => { throw new Error('JMAP request failed: 403'); });
await expect(
applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } }, onlineRun),
).rejects.toThrow('403');
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('queues when the online attempt fails with a connectivity error', async () => {
const err = new Error('Network request failed');
err.name = 'NetworkError';
const onlineRun = vi.fn(async () => { throw err; });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }, onlineRun);
expect(result.queued).toBe(true);
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
});
describe('flush', () => {
it('replays queued ops in order and clears them', async () => {
useNetworkStore.setState({ online: false });
const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } });
store.enqueue({ kind: 'destroy', emailId: 'e3' });
useNetworkStore.setState({ online: true });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true });
expect(destroyEmails).toHaveBeenCalledWith(['e3']);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('does nothing while offline', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1);
});
it('stops and retains the op on a transient failure', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
const err = new Error('Network request failed');
err.name = 'NetworkError';
setEmailKeywords.mockRejectedValueOnce(err);
await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].lastError).toContain('Network');
});
it('drops a poison op after repeated server rejections', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400'));
// MAX_ATTEMPTS is 5; flush bumps one attempt per run.
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
});
+75 -26
View File
@@ -5,8 +5,9 @@ import { useAccountStore } from './account-store';
import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -40,6 +41,7 @@ export interface AuthState {
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -55,6 +57,7 @@ function clearAllFeatureStores(): void {
useEmailStore.getState().clearAllAccounts();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
useFilterStore.getState().clearState();
}
// Drop the named account from the email cache, then reset the (per-session,
@@ -72,6 +75,7 @@ function clearAccountFeatureStores(accountId: string | null): void {
// follow-up.
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
useFilterStore.getState().clearState();
}
function refetchFeatureStores(): void {
@@ -94,6 +98,43 @@ function refetchFeatureStores(): void {
}
}
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
// pairing both end here). Bootstraps a JMAP session from the token bundle,
// registers the account, and flips the store to connected. Throws on failure
// so the caller can surface a flow-specific error.
async function completeOAuthHandoff(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
): Promise<void> {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
}
function applyConnectedState(
set: (partial: Partial<AuthState>) => void,
session: JMAPSession,
@@ -197,31 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
@@ -234,6 +251,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaPairing: async (webmailUrl, code, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await redeemPairingCode(webmailUrl, code);
} catch (err) {
const message = err instanceof Error ? err.message : 'Pairing failed';
set({ isLoading: false, error: message });
throw err;
}
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
// future server change can't silently mis-route credentials here.
if (result.flow !== 'oauth') {
set({ isLoading: false, error: 'Unexpected pairing response' });
throw new Error('Unexpected pairing response');
}
try {
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'Pairing sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
+8 -5
View File
@@ -376,9 +376,9 @@ export function selectGroups(state: ContactsState): ContactCard[] {
return state.contacts.filter(isGroup);
}
export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: string; count: number }> {
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
const counts = new Map<string, number>();
for (const contact of state.contacts) {
for (const contact of contacts) {
for (const kw of getContactKeywords(contact)) {
counts.set(kw, (counts.get(kw) || 0) + 1);
}
@@ -388,10 +388,13 @@ export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: strin
.sort((a, b) => a.keyword.localeCompare(b.keyword));
}
export function selectAddressBooksWithCount(state: ContactsState): Array<AddressBook & { count: number }> {
return state.addressBooks.map((book) => ({
export function selectAddressBooksWithCount(
addressBooks: AddressBook[],
contacts: ContactCard[],
): Array<AddressBook & { count: number }> {
return addressBooks.map((book) => ({
...book,
count: state.contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
}));
}
+212 -35
View File
@@ -14,6 +14,7 @@ import {
getEmailsWithState,
getEmailChanges,
getFullEmail,
importEmailBlob,
setEmailKeywords,
setKeywordsForEmails,
moveEmail,
@@ -28,6 +29,30 @@ import { toWildcardQuery } from '../lib/search-utils';
import { generateAccountId } from '../lib/account-utils';
import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store';
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
// Keep the offline body cache consistent with an optimistic/queued mutation so
// re-opening a message while offline shows the change. Fire-and-forget.
function patchCache(id: string, changes: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> }): void {
void useOfflineCacheStore.getState().patch(id, changes);
}
function dropFromCache(ids: string[]): void {
void useOfflineCacheStore.getState().remove(ids);
}
// Compute an email's full mailboxIds map after removing one mailbox and adding
// another — the idempotent target the outbox replays for a move/trash.
function mailboxesAfterMove(
current: Record<string, boolean> | undefined,
fromMailboxId: string | null,
toMailboxId: string,
): Record<string, boolean> {
const next: Record<string, boolean> = {};
for (const [id, present] of Object.entries(current ?? {})) {
if (present && id !== fromMailboxId) next[id] = true;
}
next[toMailboxId] = true;
return next;
}
// True only when the JMAP client is actually serving the email-store's active
// account. During an account switch there's a window between
@@ -124,9 +149,13 @@ export interface EmailState {
selectMailbox: (mailboxId: string) => Promise<void>;
loadMoreEmails: () => Promise<void>;
refreshEmails: () => Promise<void>;
importEmails: (
files: { uri: string; name: string; mimeType?: string }[],
mailboxId: string,
) => Promise<{ imported: number; failed: number }>;
handleStateChange: (change: StateChange) => Promise<void>;
getEmailDetail: (id: string) => Promise<Email>;
markRead: (emailId: string) => Promise<void>;
getEmailDetail: (id: string, accountId?: string) => Promise<Email>;
markRead: (emailId: string, accountId?: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
togglePin: (emailId: string, pinned: boolean) => Promise<void>;
@@ -347,6 +376,11 @@ export const useEmailStore = create<EmailState>()(
// and-forget — the cache returns empty until hydration completes,
// which is the correct degraded behaviour.
void useOfflineCacheStore.getState().setAccount(accountId);
// Load the new account's outbox and try to drain it (no-op when offline or
// the JMAP client isn't serving this account yet).
void useOutboxStore.getState().setAccount(accountId).then(() => {
void useOutboxStore.getState().flush();
});
},
removeAccount: (accountId) => {
@@ -369,6 +403,7 @@ export const useEmailStore = create<EmailState>()(
pendingUndo: null,
});
void useOfflineCacheStore.getState().setAccount(null);
void useOutboxStore.getState().setAccount(null);
} else {
set({ accountSnapshots: rest });
}
@@ -393,6 +428,7 @@ export const useEmailStore = create<EmailState>()(
loading: false,
});
void useOfflineCacheStore.getState().setAccount(null);
void useOutboxStore.getState().setAccount(null);
},
fetchMailboxes: async () => {
@@ -572,6 +608,38 @@ export const useEmailStore = create<EmailState>()(
}
},
importEmails: async (files, mailboxId) => {
// Loaded lazily so the store module stays free of expo-file-system at
// import time (that native dep can't load in the test/SSR environment).
const { uploadBytes } = await import('../api/blob');
const { expandImportableEml } = await import('../lib/eml-import');
let imported = 0;
let failed = 0;
for (const file of files) {
try {
// A .eml expands to one message; a .zip to one per .eml it contains.
const emls = await expandImportableEml(file.uri, file.name, file.mimeType);
if (emls.length === 0) failed += 1;
for (const eml of emls) {
try {
const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822');
await importEmailBlob(blobId, mailboxId);
imported += 1;
} catch {
failed += 1;
}
}
} catch {
failed += 1;
}
}
// Surface freshly imported messages if we imported into the open mailbox.
if (imported > 0 && get().currentMailboxId === mailboxId) {
await get().refreshEmails();
}
return { imported, failed };
},
refreshEmails: async () => {
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
@@ -784,13 +852,14 @@ export const useEmailStore = create<EmailState>()(
void get().refreshEmails();
},
getEmailDetail: async (id) => {
getEmailDetail: async (id, accountId) => {
// Try the network first so the user sees fresh keywords/flags. If that
// fails (offline / server unreachable), fall back to the offline cache
// when the message is in it. Without the cache hit, propagate the error
// so the caller can surface it.
// so the caller can surface it. `accountId` targets a group/shared inbox
// message opened from the unified view.
try {
const fresh = await getFullEmail(id);
const fresh = await getFullEmail(id, accountId);
// Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState();
@@ -808,27 +877,36 @@ export const useEmailStore = create<EmailState>()(
}
},
markRead: async (emailId) => {
markRead: async (emailId, accountId) => {
const email = get().emails.find((e) => e.id === emailId);
const nextKeywords = { ...(email?.keywords ?? {}), $seen: true };
await setEmailKeywords(emailId, nextKeywords);
// A group/shared message opened from the unified inbox lives under another
// JMAP account and isn't in the active list/cache or the (account-scoped)
// offline queue — mark it read directly against its owning account.
if (accountId && !email) {
await setEmailKeywords(emailId, nextKeywords, accountId);
return;
}
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: nextKeywords } : e,
),
});
patchCache(emailId, { keywords: nextKeywords });
},
markUnread: async (emailId) => {
const email = get().emails.find((e) => e.id === emailId);
if (!email) return;
const { $seen, ...rest } = email.keywords;
await setEmailKeywords(emailId, rest);
await applyOrQueue({ kind: 'keywords', emailId, keywords: rest });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: rest } : e,
),
});
patchCache(emailId, { keywords: rest });
},
toggleStar: async (emailId, starred) => {
@@ -840,12 +918,13 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$flagged;
}
await setEmailKeywords(emailId, keywords);
await applyOrQueue({ kind: 'keywords', emailId, keywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
patchCache(emailId, { keywords });
},
togglePin: async (emailId, pinned) => {
@@ -857,20 +936,26 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$important;
}
await setEmailKeywords(emailId, keywords);
await applyOrQueue({ kind: 'keywords', emailId, keywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
patchCache(emailId, { keywords });
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const target = mailboxesAfterMove(email?.mailboxIds, fromMailboxId, toMailboxId);
await moveEmail(emailId, fromMailboxId, toMailboxId);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => moveEmail(emailId, fromMailboxId, toMailboxId),
);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
patchCache(emailId, { mailboxIds: target });
if (email && original) {
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
@@ -899,11 +984,17 @@ export const useEmailStore = create<EmailState>()(
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
await apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
// Online keeps the rich year/month auto-foldering. Offline degrades to the
// archive root (we can't create folders without a connection); the queued
// op replays as a plain move into Archive.
const { queued } = await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: { [archiveMailbox.id]: true } },
() => apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
),
);
set({
@@ -915,10 +1006,12 @@ export const useEmailStore = create<EmailState>()(
items: [{ email, originalMailboxIds: original }],
},
});
patchCache(emailId, { mailboxIds: { [archiveMailbox.id]: true } });
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render.
if (mode !== 'single') {
// mailbox list so the sidebar picks them up on the next render. Skip when
// the action was only queued (no folders were created offline).
if (mode !== 'single' && !queued) {
void get().fetchMailboxes();
}
},
@@ -945,9 +1038,26 @@ export const useEmailStore = create<EmailState>()(
if (destroy) {
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
// destroy branch even when the source folder isn't trash.
await apiDeleteEmail(emailId, trashMailboxId, trashMailboxId);
await applyOrQueue(
{ kind: 'destroy', emailId },
() => apiDeleteEmail(emailId, trashMailboxId, trashMailboxId),
);
dropFromCache([emailId]);
} else {
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
const target = mailboxesAfterMove(email?.mailboxIds, currentMailboxId, trashMailboxId);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => apiDeleteEmail(emailId, trashMailboxId, currentMailboxId),
);
// "Move to Trash and mark as read" (#323): when the user picked that
// delete action, also clear unread state for messages moved to trash.
if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) {
const nextKeywords = { ...email.keywords, $seen: true };
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
patchCache(emailId, { mailboxIds: target, keywords: nextKeywords });
} else {
patchCache(emailId, { mailboxIds: target });
}
}
set({ emails: get().emails.filter((e) => e.id !== emailId) });
@@ -982,12 +1092,16 @@ export const useEmailStore = create<EmailState>()(
const mode = useSettingsStore.getState().archiveMode;
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
const archiveTarget = { [archiveMailbox.id]: true };
await apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
mode,
mailboxes,
const { queued } = await applyOrQueueBatch(
targets.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, mailboxIds: archiveTarget })),
() => apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
mode,
mailboxes,
),
);
const removed = new Set(targets.map((e) => e.id));
@@ -1000,8 +1114,9 @@ export const useEmailStore = create<EmailState>()(
items,
},
});
for (const e of targets) patchCache(e.id, { mailboxIds: archiveTarget });
if (mode !== 'single') void get().fetchMailboxes();
if (mode !== 'single' && !queued) void get().fetchMailboxes();
},
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
@@ -1012,9 +1127,19 @@ export const useEmailStore = create<EmailState>()(
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
await apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId);
await applyOrQueueBatch(
targets.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId),
})),
() => apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId),
);
const removed = new Set(targets.map((e) => e.id));
for (const e of targets) {
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId) });
}
const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
emails: get().emails.filter((e) => !removed.has(e.id)),
@@ -1051,11 +1176,49 @@ export const useEmailStore = create<EmailState>()(
(destroy ? toDestroy : toTrash).push(e);
}
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
// "Move to Trash and mark as read" (#323): also clear unread state for the
// moved-to-trash messages when that delete action is selected.
const toMarkRead =
settings.deleteAction === 'trash-and-read'
? toTrash.filter((e) => !e.keywords?.$seen)
: [];
const markReadKeywords = new Map(
toMarkRead.map((e) => [e.id, { ...e.keywords, $seen: true }]),
);
const ops: OutboxOp[] = [
...toDestroy.map((e): OutboxOp => ({ kind: 'destroy', emailId: e.id })),
...toTrash.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
})),
...toMarkRead.map((e): OutboxOp => ({
kind: 'keywords',
emailId: e.id,
keywords: markReadKeywords.get(e.id)!,
})),
];
await applyOrQueueBatch(ops, async () => {
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
}
if (toMarkRead.length > 0) {
await setKeywordsForEmails(
toMarkRead.map((e) => ({ id: e.id, keywords: markReadKeywords.get(e.id)! })),
);
}
});
if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id));
for (const e of toTrash) {
patchCache(e.id, {
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}),
});
}
const removed = new Set(targets.map((e) => e.id));
@@ -1083,13 +1246,17 @@ export const useEmailStore = create<EmailState>()(
else delete keywords[token];
return { id: e.id, keywords };
});
await setKeywordsForEmails(updates);
await applyOrQueueBatch(
updates.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, keywords: u.keywords })),
() => setKeywordsForEmails(updates),
);
const byId = new Map(updates.map((u) => [u.id, u.keywords]));
set({
emails: get().emails.map((e) =>
byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e,
),
});
for (const u of updates) patchCache(u.id, { keywords: u.keywords });
},
undoLast: async () => {
@@ -1098,9 +1265,19 @@ export const useEmailStore = create<EmailState>()(
set({ pendingUndo: null });
try {
await restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
await applyOrQueueBatch(
entry.items.map((it): OutboxOp => ({
kind: 'mailboxes',
emailId: it.email.id,
mailboxIds: it.originalMailboxIds,
})),
() => restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
),
);
for (const it of entry.items) {
patchCache(it.email.id, { mailboxIds: it.originalMailboxIds });
}
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Undo failed' });
return;
+203
View File
@@ -0,0 +1,203 @@
import { create } from 'zustand';
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '../lib/sieve/types';
import { parseScript } from '../lib/sieve/parser';
import { generateScript } from '../lib/sieve/generator';
import {
createSieveScript,
getSieveCapabilities,
getSieveScriptContent,
getSieveScripts,
isSieveSupported,
updateSieveScript,
validateSieveScript,
} from '../api/sieve';
// Ported from the webmail's stores/filter-store.ts. The mobile client is a
// singleton (api/sieve drives `jmapClient` directly), so the actions drop the
// `client` argument the web store threads through.
interface FilterStore {
rules: FilterRule[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
sieveCapabilities: SieveCapabilities | null;
activeScriptId: string | null;
isOpaque: boolean;
rawScript: string;
vacationSettings: VacationSieveConfig | null;
externalRequires: string[];
fetchFilters: () => Promise<void>;
saveFilters: () => Promise<void>;
validateScript: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
deleteRule: (ruleId: string) => void;
reorderRules: (ruleIds: string[]) => void;
toggleRule: (ruleId: string) => void;
setRawScript: (content: string) => void;
setOpaqueScript: (content: string) => void;
resetToVisualBuilder: () => void;
clearState: () => void;
}
export const useFilterStore = create<FilterStore>()((set, get) => ({
rules: [],
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
sieveCapabilities: null,
activeScriptId: null,
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
fetchFilters: async () => {
if (!isSieveSupported()) {
set({ isSupported: false, isLoading: false });
return;
}
set({ isLoading: true, error: null, isSupported: true });
try {
set({ sieveCapabilities: getSieveCapabilities() });
const allScripts = await getSieveScripts();
// Skip the server-managed 'vacation' script (RFC 9661 §4) - it can only
// be modified via VacationResponse/set, not SieveScript/set.
const scripts = allScripts.filter((s) => s.name !== 'vacation');
const activeScript = scripts.find((s) => s.isActive) || scripts[0];
if (!activeScript) {
set({ isLoading: false, rules: [], activeScriptId: null, rawScript: '', isOpaque: false });
return;
}
set({ activeScriptId: activeScript.id });
const content = await getSieveScriptContent(activeScript.blobId);
set({ rawScript: content });
const result = parseScript(content);
set({
isLoading: false,
isOpaque: result.isOpaque,
rules: result.isOpaque ? [] : result.rules,
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch filters',
});
}
},
saveFilters: async () => {
set({ isSaving: true, error: null });
try {
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
const content = isOpaque
? rawScript
: generateScript(rules, vacationSettings || undefined, { externalRequires });
if (activeScriptId) {
await updateSieveScript(activeScriptId, content, true);
} else {
const script = await createSieveScript('filters', content, true);
set({ activeScriptId: script.id });
}
set({ isSaving: false, rawScript: content });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to save filters',
});
throw error;
}
},
validateScript: async (content) => validateSieveScript(content),
addRule: (rule) => {
// Insert new bulwark rules before external/opaque rules so Bulwark's
// managed section stays contiguous.
set((state) => {
const bulwark = state.rules.filter((r) => !r.origin || r.origin === 'bulwark');
const external = state.rules.filter((r) => r.origin === 'external' || r.origin === 'opaque');
return { rules: [...bulwark, rule, ...external] };
});
},
updateRule: (ruleId, updates) => {
set((state) => ({
rules: state.rules.map((r) => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, ...updates };
}),
}));
},
deleteRule: (ruleId) => {
set((state) => ({
rules: state.rules.filter((r) => {
if (r.id !== ruleId) return true;
return r.origin === 'external' || r.origin === 'opaque';
}),
}));
},
reorderRules: (ruleIds) => {
// Only reorder bulwark rules; external rules always stay at the end in
// their original order.
set((state) => {
const bulwarkMap = new Map(
state.rules.filter((r) => !r.origin || r.origin === 'bulwark').map((r) => [r.id, r]),
);
const external = state.rules.filter((r) => r.origin === 'external' || r.origin === 'opaque');
const reordered = ruleIds.map((id) => bulwarkMap.get(id)).filter(Boolean) as FilterRule[];
return { rules: [...reordered, ...external] };
});
},
toggleRule: (ruleId) => {
set((state) => ({
rules: state.rules.map((r) => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, enabled: !r.enabled };
}),
}));
},
setRawScript: (content) => set({ rawScript: content }),
// Switch to raw-script mode in one update so the visual rules are dropped
// atomically with the new content (mirrors the webmail save-sieve flow).
setOpaqueScript: (content) => set({ isOpaque: true, rawScript: content, rules: [] }),
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
clearState: () => set({
rules: [],
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
sieveCapabilities: null,
activeScriptId: null,
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
}),
}));
+44
View File
@@ -69,7 +69,14 @@ interface OfflineCacheState {
has: (id: string) => boolean;
get: (id: string) => Promise<Email | null>;
put: (email: Email, approxBytes: number) => Promise<void>;
// Shallow-merge a change into a cached email (no-op when not cached). Used to
// keep the cached body consistent with an optimistic/queued mutation so a
// re-open while offline reflects the new keywords / mailboxIds.
patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>) => Promise<void>;
remove: (ids: string[]) => Promise<void>;
// Evict oldest-received entries until the cache fits within maxBytes. Keeps
// the persisted cache from growing without bound on a noisy account.
evictToFit: (maxBytes: number) => Promise<void>;
clearAll: () => Promise<void>;
// Returns cached emails whose `mailboxIds` include the given mailbox,
@@ -200,6 +207,21 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index);
},
patch: async (id, changes) => {
const accountId = get().activeAccountId;
if (!accountId || !get().index.entries[id]) return;
try {
const raw = await AsyncStorage.getItem(entryKey(accountId, id));
if (!raw) return;
const email = JSON.parse(raw) as Email;
const updated: Email = { ...email, ...changes };
if (get().activeAccountId !== accountId) return;
await AsyncStorage.setItem(entryKey(accountId, id), JSON.stringify(updated));
} catch (err) {
console.warn('[offline-cache] patch failed', id, err);
}
},
remove: async (ids) => {
if (ids.length === 0) return;
const accountId = get().activeAccountId;
@@ -215,6 +237,28 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index);
},
evictToFit: async (maxBytes) => {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
const accountId = get().activeAccountId;
if (!accountId) return;
let total = get().totalSize();
if (total <= maxBytes) return;
// Drop the oldest mail first (by receivedAt) — that's what users expect a
// bounded "recent mail" cache to shed when it overflows.
const sorted = Object.values(get().index.entries).sort((a, b) => {
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return at - bt;
});
const toRemove: string[] = [];
for (const entry of sorted) {
if (total <= maxBytes) break;
toRemove.push(entry.id);
total -= entry.size;
}
if (toRemove.length > 0) await get().remove(toRemove);
},
clearAll: async () => {
const accountId = get().activeAccountId;
if (!accountId) return;
+291
View File
@@ -0,0 +1,291 @@
// Persistent offline action queue ("outbox") for mail mutations.
//
// Every mutation the app performs on a message reduces to one of three
// idempotent primitives:
// - keywords: replace the full keyword map (read/unread, flag, pin, …)
// - mailboxes: replace the full mailboxIds map (move, archive, trash)
// - destroy: permanently delete the message
//
// Because each primitive assigns the *whole* target state rather than a delta,
// replaying it is safe regardless of the server's current state, so we can
// coalesce repeated edits to the same message (last-write-wins) and retry on a
// flaky connection without corrupting anything.
//
// When the device is online and nothing is already queued for a message, the
// op runs immediately (preserving today's behaviour, including surfacing real
// server errors to the caller). Otherwise it's persisted to AsyncStorage and
// replayed by flush() once connectivity returns. Storage is keyed per account,
// mirroring offline-cache-store, so queues never leak between logins.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { generateUUID } from '../lib/uuid';
import { isTransientNetworkError } from '../lib/network-error';
import { useNetworkStore } from './network-store';
import { jmapClient } from '../api/jmap-client';
import { setEmailKeywords, setEmailMailboxes, destroyEmails } from '../api/email';
const KEY_PREFIX = 'webmail:outbox:v1:';
// Give up on an op that the server keeps rejecting (a non-transient failure)
// after this many attempts so one poison entry can't wedge the whole queue.
const MAX_ATTEMPTS = 5;
function storageKey(accountId: string): string {
return `${KEY_PREFIX}${accountId}`;
}
export type OutboxOp =
| { kind: 'keywords'; emailId: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string };
export interface OutboxEntry {
id: string;
op: OutboxOp;
createdAt: number;
attempts: number;
lastError?: string;
}
interface OutboxState {
// Currently-loaded account bucket. null = detached (no reads/writes).
activeAccountId: string | null;
entries: OutboxEntry[];
hydrated: boolean;
flushing: boolean;
setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>;
enqueue: (op: OutboxOp) => void;
count: () => number;
pendingForEmail: (emailId: string) => OutboxEntry[];
flush: () => Promise<void>;
clear: () => Promise<void>;
}
function persist(accountId: string, entries: OutboxEntry[]): void {
void AsyncStorage.setItem(storageKey(accountId), JSON.stringify(entries)).catch((err) => {
console.warn('[outbox] persist failed', err);
});
}
async function load(accountId: string): Promise<OutboxEntry[]> {
try {
const raw = await AsyncStorage.getItem(storageKey(accountId));
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as OutboxEntry[];
}
} catch (err) {
console.warn('[outbox] hydrate failed', err);
}
return [];
}
async function runOp(op: OutboxOp): Promise<void> {
switch (op.kind) {
case 'keywords':
return setEmailKeywords(op.emailId, op.keywords);
case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds);
case 'destroy':
return destroyEmails([op.emailId]);
}
}
export const useOutboxStore = create<OutboxState>((set, get) => ({
activeAccountId: null,
entries: [],
hydrated: false,
flushing: false,
setAccount: async (accountId) => {
const state = get();
if (state.activeAccountId === accountId) {
if (!state.hydrated) await get().hydrate();
return;
}
set({ activeAccountId: accountId, entries: [], hydrated: false });
if (accountId) {
const entries = await load(accountId);
// Re-check in case another setAccount raced past us.
if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true });
} else {
set({ hydrated: true });
}
},
hydrate: async () => {
const state = get();
if (state.hydrated) return;
const accountId = state.activeAccountId;
if (!accountId) {
set({ hydrated: true });
return;
}
const entries = await load(accountId);
if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true });
},
enqueue: (op) => {
const accountId = get().activeAccountId;
if (!accountId) {
console.warn('[outbox] enqueue with no active account; dropping op', op.kind);
return;
}
let entries = [...get().entries];
const { emailId } = op;
if (op.kind === 'destroy') {
// Destroy is terminal — drop any pending edits for this message and
// append the destroy so it runs last.
entries = entries.filter((e) => e.op.emailId !== emailId);
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
} else {
// A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => e.op.emailId === emailId && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same kind for this message
// (full-state replace makes the latest one authoritative). Keep its
// position so creation order is preserved for replay.
const idx = entries.findIndex((e) => e.op.emailId === emailId && e.op.kind === op.kind);
if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined };
} else {
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
}
}
set({ entries });
persist(accountId, entries);
},
count: () => get().entries.length,
pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId),
flush: async () => {
if (get().flushing) return;
const accountId = get().activeAccountId;
if (!accountId) return;
if (get().entries.length === 0) return;
// Need a live client that's actually serving this account, and a network.
if (!jmapClient.isConnected) return;
if (!useNetworkStore.getState().online) return;
set({ flushing: true });
try {
// Process oldest-first so dependent moves replay in the order they were
// made. We snapshot the order but re-read the live list each iteration,
// since a concurrent enqueue may have coalesced/removed entries.
const ordered = [...get().entries].sort((a, b) => a.createdAt - b.createdAt);
for (const snapshot of ordered) {
if (get().activeAccountId !== accountId) break; // account switched
if (!useNetworkStore.getState().online) break; // went offline
if (!jmapClient.isConnected) break;
const entry = get().entries.find((e) => e.id === snapshot.id);
if (!entry) continue; // removed/coalesced
try {
await runOp(entry.op);
removeEntry(accountId, entry.id);
} catch (err) {
if (get().activeAccountId !== accountId) break;
if (isTransientNetworkError(err)) {
// Connectivity blip — stop and let the next flush retry from here.
recordError(accountId, entry.id, err, false);
break;
}
// Server rejected it. Count the attempt; drop once we've given up so
// one bad op can't block everything behind it.
const attempts = (entry.attempts ?? 0) + 1;
if (attempts >= MAX_ATTEMPTS) {
console.warn('[outbox] dropping op after repeated failures', entry.op.kind, err);
removeEntry(accountId, entry.id);
} else {
recordError(accountId, entry.id, err, true);
}
}
}
} finally {
set({ flushing: false });
}
},
clear: async () => {
const accountId = get().activeAccountId;
set({ entries: [] });
if (accountId) {
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
}
},
}));
// ── Internal helpers that mutate+persist the live list ──────────────────────
function removeEntry(accountId: string, id: string): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const entries = store.entries.filter((e) => e.id !== id);
useOutboxStore.setState({ entries });
persist(accountId, entries);
}
function recordError(accountId: string, id: string, err: unknown, incrementAttempt: boolean): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const message = err instanceof Error ? err.message : String(err);
const entries = store.entries.map((e) =>
e.id === id
? { ...e, attempts: incrementAttempt ? (e.attempts ?? 0) + 1 : e.attempts, lastError: message }
: e,
);
useOutboxStore.setState({ entries });
persist(accountId, entries);
}
// ── Public entry points used by the email-store mutations ───────────────────
export interface ApplyResult {
/** True when the op was deferred to the queue instead of running now. */
queued: boolean;
}
// Run a batch of ops now when we're online and nothing is already queued for
// any of the affected messages; otherwise queue them for later replay. An
// optional `onlineRun` lets callers keep a richer single-round-trip online
// path (e.g. batch move, or archive's year/month auto-foldering) while still
// degrading to the idempotent primitives offline.
export async function applyOrQueueBatch(
ops: OutboxOp[],
onlineRun?: () => Promise<void>,
): Promise<ApplyResult> {
if (ops.length === 0) return { queued: false };
const store = useOutboxStore.getState();
const online = useNetworkStore.getState().online && jmapClient.isConnected;
const hasQueued = ops.some((op) =>
store.entries.some((e) => e.op.emailId === op.emailId),
);
if (online && !hasQueued) {
try {
await (onlineRun ? onlineRun() : Promise.all(ops.map(runOp)).then(() => undefined));
return { queued: false };
} catch (err) {
// A real server/validation error should bubble up exactly like before.
// Only fall through to the queue when the failure is connectivity.
if (!isTransientNetworkError(err)) throw err;
}
}
for (const op of ops) store.enqueue(op);
if (online) void store.flush();
return { queued: true };
}
export function applyOrQueue(op: OutboxOp, onlineRun?: () => Promise<void>): Promise<ApplyResult> {
return applyOrQueueBatch([op], onlineRun);
}
+93 -1
View File
@@ -7,7 +7,7 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type MailLayout = 'split' | 'focus';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
@@ -21,16 +21,72 @@ export type SwipeAction =
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
// stays reachable.
export type QuickAction =
| 'reply'
| 'replyAll'
| 'forward'
| 'delete'
| 'archive'
| 'markUnread'
| 'star'
| 'move'
| 'spam'
| 'tag';
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
export const ALL_QUICK_ACTIONS: QuickAction[] = [
'reply',
'replyAll',
'forward',
'delete',
'archive',
'markUnread',
'star',
'move',
'spam',
'tag',
];
// The reader bottom bar always shows exactly three quick actions (between the
// prev/next nav buttons). Coerce any persisted value into three unique, valid
// ids, backfilling from the reply-family default when entries are missing.
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
const out: QuickAction[] = [];
if (Array.isArray(value)) {
for (const a of value) {
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
out.push(a as QuickAction);
}
}
}
for (const d of REPLY_QUICK_ACTIONS) {
if (out.length >= 3) break;
if (!out.includes(d)) out.push(d);
}
return out.slice(0, 3);
}
export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type FirstDayOfWeek = 0 | 1;
export type TimeFormat = '12h' | '24h';
// Email-list date rendering style. Mirrors the webmail `dateFormat` setting:
// smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date)
// relative — "1h ago", "2d ago"
// full — always the full locale date + time
export type DateFormat = 'smart' | 'relative' | 'full';
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
export type FilesFolderLayout = 'inline' | 'sidebar';
export type FilesViewMode = 'list' | 'grid';
export type FilesSortKey = 'name' | 'size' | 'modified';
export type FilesSortDir = 'asc' | 'desc';
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
// Filename transform for downloads/exports (mirrors webmail SpaceReplacement).
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
const STORAGE_KEY = 'webmail:settings:v1';
@@ -51,6 +107,14 @@ interface PersistedSettings {
senderFavicons: boolean;
hideInlineImageAttachments: boolean;
// Language, region & time
dateFormat: DateFormat;
timeFormat: TimeFormat;
// Unified inbox: also pull in group/shared inboxes reachable through each
// logged-in account (parity with the webmail `includeGroupInUnified` setting).
includeGroupInUnified: boolean;
// Contacts
groupContactsByLetter: boolean;
@@ -88,6 +152,10 @@ interface PersistedSettings {
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
// family; reply-family actions removed from here move to the top toolbar.
bottomQuickActions: QuickAction[];
// Archive
archiveMode: ArchiveMode;
@@ -135,15 +203,30 @@ interface PersistedSettings {
// Plugins (UI-level enable map)
pluginEnabled: Record<string, boolean>;
// Downloads / export filenames: templates and a filename transform applied
// when exporting a message as .eml or saving an attachment.
emailExportTemplate: string;
attachmentExportTemplate: string;
exportSpaceReplacement: SpaceReplacement;
exportLowercase: boolean;
exportStripDiacritics: boolean;
// Offline mail cache: download recent message bodies in the background so
// they can be opened without network. Days windows the lookback. Attachment
// caching is intentionally not implemented yet — bodies-only is much
// smaller and covers the "open recent mail offline" UX on its own.
offlineCacheEnabled: boolean;
offlineCacheDays: number;
// Hard cap on the on-disk body cache, in megabytes. When a sync pushes the
// cache past this, the oldest messages are evicted to fit.
offlineCacheMaxMB: number;
}
const DEFAULT_PERSISTED: PersistedSettings = {
dateFormat: 'smart',
timeFormat: '24h',
includeGroupInUnified: false,
externalContentPolicy: 'ask',
trustedSenders: [],
trustedSendersAddressBook: false,
@@ -180,6 +263,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
swipeRightAction: 'read',
swipeMode: 'instant',
bottomQuickActions: ['reply', 'replyAll', 'forward'],
archiveMode: 'single',
calendarDefaultView: 'month',
@@ -219,8 +304,15 @@ const DEFAULT_PERSISTED: PersistedSettings = {
pluginEnabled: {},
emailExportTemplate: '{date} ({from}-{to}) {subject}',
attachmentExportTemplate: '{filename}',
exportSpaceReplacement: 'keep',
exportLowercase: false,
exportStripDiacritics: false,
offlineCacheEnabled: false,
offlineCacheDays: 7,
offlineCacheMaxMB: 50,
};
export interface SettingsState extends PersistedSettings {