diff --git a/App.tsx b/App.tsx index 00d68a83..134c43ed 100644 --- a/App.tsx +++ b/App.tsx @@ -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 diff --git a/locales/en/common.json b/locales/en/common.json index 4a6860f0..42357a66 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 62b1301c..3e506ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,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 +4758,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", @@ -6202,6 +6209,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 +6331,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 +6838,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 +6877,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 +8046,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 +8263,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 +8637,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 +9062,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 +9305,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 +9856,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", diff --git a/package.json b/package.json index 7c13d7c5..bb1f5319 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,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", diff --git a/src/api/account-security.ts b/src/api/account-security.ts new file mode 100644 index 00000000..f23da402 --- /dev/null +++ b/src/api/account-security.ts @@ -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]; + +// 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>(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][]): Promise { + const res = await jmapClient.request(methodCalls, STALWART_USING); + return res.methodResponses as MethodResponse[]; +} + +function credentialFromResult(raw: Record): AppCredentialInfo { + const allowedIps = + raw.allowedIps && typeof raw.allowedIps === 'object' + ? Object.keys(raw.allowedIps as Record) + : []; + 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 | undefined { + if (!ips || ips.length === 0) return undefined; + return Object.fromEntries(ips.map((ip) => [ip, true])); +} + +function buildCreateBody(input: AppCredentialInput): Record { + const body: Record = { 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 { + 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][] = []; + 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> }>(followUpResponses, 'app'); + appPasswords = (r.list ?? []).map(credentialFromResult); + } + if (apiKeyQuery.ids?.length) { + const r = resultFor<{ list?: Array> }>(followUpResponses, 'key'); + apiKeys = (r.list ?? []).map(credentialFromResult); + } + } + + return { otpEnabled, appPasswords, apiKeys }; +} + +export async function fetchEncryptionType(): Promise { + 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 { + 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; + 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 { + const accountId = jmapClient.accountId; + const responses = await send([ + [ + 'x:AccountPassword/set', + { accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } }, + '0', + ], + ]); + const result = resultFor<{ notUpdated?: Record }>(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 { + const accountId = jmapClient.accountId; + const responses = await send([ + ['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'], + ]); + const result = resultFor<{ notUpdated?: Record }>(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 { + 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 }>(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 { + 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 }>(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; + notCreated?: Record; + }>(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 { + const accountId = jmapClient.accountId; + const responses = await send([[method, { accountId, destroy: [id] }, '0']]); + const result = resultFor<{ notDestroyed?: Record }>(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 { + 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 { + return removeCredential('x:ApiKey/set', id); +} diff --git a/src/api/email.ts b/src/api/email.ts index 548aa89e..b24be026 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -283,8 +283,10 @@ export async function getEmailChanges( }; } -export async function getFullEmail(id: string): Promise { - const accountId = jmapClient.accountId; +export async function getFullEmail(id: string, accountIdOverride?: string): Promise { + // `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 { 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 = { $seen: true }, +): Promise { + 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 { const accountId = jmapClient.accountId; const res = await jmapClient.request( @@ -332,8 +363,9 @@ export async function getThread(threadId: string): Promise { export async function setEmailKeywords( emailId: string, keywords: Record, + accountIdOverride?: string, ): Promise { - 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, +): Promise { + 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 { + 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. diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 67d7de8b..22143eab 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -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; } diff --git a/src/api/sieve.ts b/src/api/sieve.ts new file mode 100644 index 00000000..aef173cd --- /dev/null +++ b/src/api/sieve.ts @@ -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 { + 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 { + 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 { + 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; + 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 { + const blobId = await uploadSieveBlob(content); + const accountId = getSieveAccountId(); + + const setArgs: Record = { + 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; + created?: Record; + }; + 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 { + const blobId = await uploadSieveBlob(content); + const accountId = getSieveAccountId(); + + const setArgs: Record = { + 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 }; + 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 { + 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 }; + 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'] }; +} diff --git a/src/api/types.ts b/src/api/types.ts index 0845c261..f6324711 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; } export type JMAPMethodCall = [string, Record, string]; diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 1da5b398..75b441fd 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -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 { - 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 { 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 { + 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((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 { + const { includeGroup = false } = opts; const errors: Record = {}; 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[]; diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index b27c9e85..8f1f63c1 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -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 ( - You are offline{hint ? ` — ${hint}` : ''} + You are offline{suffix ? ` — ${suffix}` : ''} ); diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx new file mode 100644 index 00000000..ee0057ed --- /dev/null +++ b/src/components/filters/FilterRuleModal.tsx @@ -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(['move', 'copy', 'forward', 'reject', 'add_label']); +const ACTIONS_WITH_MAILBOX = new Set(['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( + rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()], + ); + const [actions, setActions] = useState( + 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) => { + 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) => { + 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 ( + + + + + + + + {isEdit ? t('settings.filters.edit_rule', 'Edit Rule') : t('settings.filters.new_rule', 'New Rule')} + + + + + + + {/* Name */} + + {t('settings.filters.rule_name', 'Rule Name')} + + + + {/* Match type */} + + {t('settings.filters.match_type', 'Match type')} + + {(['all', 'any'] as const).map((mt) => { + const selected = matchType === mt; + return ( + setMatchType(mt)} + style={[styles.matchBtn, selected ? styles.matchBtnOn : styles.matchBtnOff]} + > + + {mt === 'all' + ? t('settings.filters.match_all', 'Match ALL conditions') + : t('settings.filters.match_any', 'Match ANY condition')} + + + ); + })} + + + + {/* Conditions */} + + {t('settings.filters.conditions', 'Conditions')} + + {conditions.map((condition, index) => ( + + + updateCondition(index, { headerName: v })} + placeholder={t('settings.filters.header_name', 'Header name')} + /> + )} + + 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'} + /> + + ))} + + setConditions((prev) => [...prev, makeEmptyCondition()])} + style={styles.addRow} + > + + {t('settings.filters.add_condition', 'Add Condition')} + + + + {/* Actions */} + + {t('settings.filters.actions', 'Actions')} + + {actions.map((action, index) => ( + + + updateAction(index, { value: v })} + options={mailboxOptions} + style={{ alignSelf: 'stretch' }} + /> + ) : ( + updateAction(index, { value: v })} + placeholder={t('settings.filters.move_to_folder', 'Select folder')} + /> + ) + )} + + {action.type === 'forward' && ( + updateAction(index, { value: v })} + placeholder={t('settings.filters.forward_placeholder', 'email@example.com')} + keyboardType="email-address" + autoCapitalize="none" + /> + )} + + {action.type === 'reject' && ( + updateAction(index, { value: v })} + placeholder={t('settings.filters.reject_placeholder', 'Your email has been rejected')} + /> + )} + + {action.type === 'add_label' && ( + keywordOptions.length > 0 ? ( + updateAction(index, { value: v })} + placeholder={t('settings.filters.label_placeholder', 'Select tag')} + /> + ) + )} + + ))} + + setActions((prev) => [...prev, makeEmptyAction()])} + style={styles.addRow} + > + + {t('settings.filters.add_action', 'Add Action')} + + + + {/* Stop processing */} + + + {t('settings.filters.stop_processing', 'Stop processing subsequent rules')} + + + + + + + + + + + + + ); +} + +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, + }, + }); +} diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx new file mode 100644 index 00000000..b9252532 --- /dev/null +++ b/src/components/filters/SieveEditorSheet.tsx @@ -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 ( + + + + + + + {t('settings.filters.sieve_editor.title', 'Sieve Script Editor')} + + + + + + + + + {t('settings.filters.sieve_editor.warning', 'Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.')} + + + + + { + 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')} + /> + + + {lineCount} {lineCount === 1 ? 'line' : 'lines'} + + {validationResult && ( + + {validationResult.isValid ? ( + <> + + + {t('settings.filters.sieve_editor.valid', 'Script is valid')} + + + ) : ( + <> + + + + {t('settings.filters.sieve_editor.invalid', 'Script has errors')} + + {validationResult.errors?.map((err, i) => ( + {err} + ))} + + + )} + + )} + + {showSaveWarning && ( + + + + {t('settings.filters.sieve_editor.save_warning', 'Saving will overwrite any visual rules. This cannot be undone. Tap Save again to confirm.')} + + + )} + + + + + + + + + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/settings/AboutDataSettings.tsx b/src/components/settings/AboutDataSettings.tsx index 99b814b9..705e116d 100644 --- a/src/components/settings/AboutDataSettings.tsx +++ b/src/components/settings/AboutDataSettings.tsx @@ -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} /> + + + setShowCurrent((v) => !v)}> + {showCurrent ? : } + - - + + + + ); +} + +// ── 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 ( + + + + + + + ); +} + +// ── 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(null); + const [disableOpen, setDisableOpen] = useState(false); + const [password, setPassword] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [error, setError] = useState(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 ( + + + + Two-Factor Authentication + + + + + + + {enabled ? 'Active' : 'Inactive'} + + + + + {enrolment && ( + + + Add this secret to your authenticator app, then enter the 6-digit code to confirm. + + + + Or enter this secret manually + {enrolment.secretFormatted} + + + Current password + + + + Verification code + + + {error ? {error} : null} + + - {certAlias && ( - + + + )} + + {disableOpen && ( + + Enter your password to turn off two-factor authentication. + + {error ? {error} : null} + + + + + + )} + + ); +} + +// ── 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; +} + +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(null); + const [showPicker, setShowPicker] = useState(false); + const [allowedIpsRaw, setAllowedIpsRaw] = useState(''); + const [createdSecret, setCreatedSecret] = useState(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 ( + + + + + {title} + + + + {description} + + {createdSecret && ( + + Copy this now — it is shown only once. Tap and hold to select. + {createdSecret} + + + + + )} + + {showAdd && ( + + + {nameLabel} + + + + Expires (optional) + + + {expiry && ( + setExpiry(null)}> + + + )} + + {showPicker && ( + )} + + Allowed IPs (optional) + + Comma or space separated. Leave empty to allow any IP. + + + + + + + )} + + {entries.length > 0 ? ( + + {entries.map((entry) => ( + + + {entry.description || entry.id} + {entry.createdAt && ( + + {new Date(entry.createdAt).toLocaleDateString()} + {entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''} + + )} + {entry.allowedIps.length > 0 && ( + + {entry.allowedIps.map((ip) => ( + {ip} + ))} + + )} + + handleRemove(entry.id)}> + + + + ))} + + ) : ( + None yet. + )} + + ); +} + +// ── Email client (OAuth accounts) ───────────────────────── +function EmailClientSection() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const username = jmapClient.username ?? ''; + + return ( + + + + Email client setup + + + To configure another mail client, use the username below and an app password (create one above) as the password. + + + JMAP / IMAP username + {username} + + + ); +} + +// ── 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 ( + + + + Encryption at rest + + + + {enabled ? `Active (${type})` : 'Disabled'} + + + + ); +} + +// ── 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(null); + const [auth, setAuth] = useState(null); + const [displayName, setDisplayName] = useState(''); + const [encryption, setEncryption] = useState('Disabled'); + const [loadError, setLoadError] = useState(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 ( + + {certSupported && } + + {supported === null && ( + + + Detecting server features… + + )} + + {supported === false && ( + + + Account security management requires a Stalwart server. These features are not available for this account. + )} - - - - Current password - - - setShowCurrent((v) => !v)}> - {showCurrent ? ( - - ) : ( - - )} - - - + {supported && ( + + + {loadError ? {loadError} : null} - - New password - - - setShowNew((v) => !v)}> - {showNew ? ( - - ) : ( - - )} - - - - - - Confirm password - - - - - - - - - - - - - - - {twoFactor && ( - - - - Scan the QR code in your authenticator app to finish setup. - - - )} - - - - {appPwds.length === 0 ? ( - - - No app passwords yet. - - ) : null} - - - - - - - {MOCK_SESSIONS.map((s) => ( - - - - - - {s.device} - - {s.location} · {s.current ? 'Current' : 'Active'} - - - {!s.current && ( - - - + {!isOAuth && ( + <> + + + { void reloadAuth(); }} /> + )} + + { const r = await createAppPassword(input); await reloadAuth(); return r; }} + onRemove={async (id) => { await removeAppPassword(id); await reloadAuth(); }} + /> + + { const r = await createApiKey(input); await reloadAuth(); return r; }} + onRemove={async (id) => { await removeApiKey(id); await reloadAuth(); }} + /> + + {isOAuth && } + {!isOAuth && } - ))} - + + )} ); } 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 }, + }); } diff --git a/src/components/settings/DownloadsSettings.tsx b/src/components/settings/DownloadsSettings.tsx new file mode 100644 index 00000000..87db3980 --- /dev/null +++ b/src/components/settings/DownloadsSettings.tsx @@ -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 ( + + + update('emailExportTemplate', v)} + autoCapitalize="none" + autoCorrect={false} + /> + update('emailExportTemplate', DEFAULT_EMAIL_TEMPLATE)} + style={styles.resetRow} + > + + Reset to default + + + Preview + {emailPreview} + + + + + + update('attachmentExportTemplate', v)} + autoCapitalize="none" + autoCorrect={false} + /> + update('attachmentExportTemplate', DEFAULT_ATTACHMENT_TEMPLATE)} + style={styles.resetRow} + > + + Reset to default + + + Preview + {attachmentPreview} + + + + + + + + update('exportSpaceReplacement', v as SpaceReplacement)} + options={[ + { value: 'keep', label: 'Keep' }, + { value: 'underscore', label: 'Underscore' }, + { value: 'dash', label: 'Dash' }, + ]} + /> + + + + update('exportLowercase', v)} /> + + + + update('exportStripDiacritics', v)} /> + + + + ); +} + +function TokenList({ + tokens, + styles, +}: { + tokens: { token: string; description: string }[]; + styles: ReturnType; +}) { + return ( + + {tokens.map((t) => ( + + {`{${t.token}}`} + + ))} + + ); +} + +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 }, + }); +} diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index bb973d1f..c2cc8af8 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -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(SAMPLE_RULES); - const [isOpaque] = useState(false); - const [deleteId, setDeleteId] = useState(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(); + 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 ( + + {t('settings.filters.not_supported', 'Your mail server does not support email filters.')} + + ); + } + + if (isLoading) { + return ( + + + + {t('settings.filters.loading', 'Loading filters...')} + + + ); + } + + if (error) { + return ( + + {t('settings.filters.fetch_error', 'Failed to load filters')} + + ); + } return ( - - + + {isOpaque && ( - - + + - 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.')} - - Open Sieve Editor - - - - Reset to visual + setShowSieveEditor(true)}> + {t('settings.filters.open_sieve_editor', 'Open raw Sieve editor')} + {showResetConfirm ? ( + + + {t('settings.filters.confirm_reset', 'Reset')} + + setShowResetConfirm(false)}> + {t('settings.filters.cancel', 'Cancel')} + + + ) : ( + + + {t('settings.filters.reset_to_visual', 'Reset to visual builder')} + + )} )} - {rules.length === 0 && !isOpaque && ( - - - No rules yet. + {!isOpaque && showVacationBanner && ( + + + + + + {t('settings.filters.vacation_active', 'Vacation Responder is active')} + {t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')} + )} - {rules.length > 0 && ( - - {rules.map((rule) => ( - - - - + {!isOpaque && rules.length === 0 && !showVacationBanner && ( + + + {t('settings.filters.no_rules', 'No filter rules')} + + )} - toggle(rule.id)} /> + {!isOpaque && rules.length > 0 && ( + + {rules.map((rule) => { + const readonly = isReadonlyRule(rule); + const editIdx = editableIds.indexOf(rule.id); - - {rule.name} - - {rule.summary} → {rule.action} - - - - {deleteId === rule.id ? ( - - - + 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 ( + + + + + {rule.name} + {label} + + {hasStructured ? ( + + {summarizeRule(rule, t)} + + ) : rule.rawBlock ? ( + + {rule.rawBlock.trim()} + + ) : null} + - ) : ( - setDeleteId(rule.id)}> + ); + } + + return ( + + + handleToggle(rule.id)} /> + + + { setEditingRule(rule); setShowRuleModal(true); }} + > + {rule.name} + + {summarizeRule(rule, t)} + + + + + moveRule(rule.id, -1)} + disabled={editIdx <= 0} + hitSlop={6} + style={[styles.reorderBtn, editIdx <= 0 && styles.reorderBtnDisabled]} + > + + + moveRule(rule.id, 1)} + disabled={editIdx === editableIds.length - 1} + hitSlop={6} + style={[styles.reorderBtn, editIdx === editableIds.length - 1 && styles.reorderBtnDisabled]} + > + + + + + handleDelete(rule)} hitSlop={6} style={styles.deleteBtn}> - )} - - ))} + + ); + })} )} - + {!isOpaque && ( - )} - - {!isOpaque && rules.length > 0 && ( - - Expanded view - update('filtersExpandedView', v)} /> - - )} + + {isSaving && } + {!isOpaque && rules.length > 0 && ( + + {t('settings.filters.expanded_view', 'Expanded view')} + updateSetting('filtersExpandedView', v)} /> + + )} + + + { setShowRuleModal(false); setEditingRule(undefined); }} + /> + + setShowSieveEditor(false)} + onValidate={handleValidate} + /> ); } 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 }, + }); } diff --git a/src/components/settings/LanguageSettings.tsx b/src/components/settings/LanguageSettings.tsx index 5c48f20a..484dc902 100644 --- a/src/components/settings/LanguageSettings.tsx +++ b/src/components/settings/LanguageSettings.tsx @@ -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 ( - - + - - {renderRow('system', `System default (${deviceLabel})`)} - - {SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))} - - + > + + {renderRow('system', `System default (${deviceLabel})`)} + + {SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))} + + + + + 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') }, + ]} + /> + + + {t('settings.language_region.date_format.preview_today', 'Today:')} + {previewToday} + + + {t('settings.language_region.date_format.preview_this_week', 'This week:')} + {previewWeek} + + + {t('settings.language_region.date_format.preview_older', 'Older:')} + {previewOlder} + + + + + + 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') }, + ]} + /> + + ); } @@ -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 }, }); } diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index 18e2688b..bfecd072 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -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() { update('disableThreading', v)} /> + + update('includeGroupInUnified', v)} /> + + update('plainTextMode', v)} /> diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 40dc2a0d..aa828938 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -18,11 +18,22 @@ describe('buildInitialHtml', () => { receivedAt: '2026-04-27T10:00:00Z', }); expect(html).toContain('


'); - expect(html).toContain('Alice <a@b.com> 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('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 <b@c.com>'); + expect(html).toContain('From: Bob'); expect(html).toContain('Subject: Original Subject'); expect(html).toContain('forwarded body'); }); diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts new file mode 100644 index 00000000..c43b8d7f --- /dev/null +++ b/src/lib/__tests__/date-format.test.ts @@ -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'); + }); +}); diff --git a/src/lib/__tests__/download-filename.test.ts b/src/lib/__tests__/download-filename.test.ts new file mode 100644 index 00000000..378e07f6 --- /dev/null +++ b/src/lib/__tests__/download-filename.test.ts @@ -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'); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index def45fca..295c689f 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -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, '
'); } -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 ". +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 , 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 '


'; - 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 '


'; if (mode === 'forward') { + // Forwarded message block (From/Date/Subject), body appended unquoted. return [ '


', '
---------- Forwarded message ----------
', `From: ${escapeHtml(fromStr)}
`, - date ? `Date: ${escapeHtml(date)}
` : '', - subject ? `Subject: ${escapeHtml(subject)}
` : '', - reply.to && reply.to.length - ? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}
` - : '', + `Date: ${escapeHtml(date)}
`, + `Subject: ${escapeHtml(subject)}

`, '
', - `
${quotedBody}
`, + quotedBody, ].join(''); } // reply / replyAll + const headerLine = date + ? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:` + : `${escapeHtml(fromStr)} wrote:`; return [ '


', - '
', - `On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`, - '
', - `
${quotedBody}
`, + `
${headerLine}
`, + `
${quotedBody}
`, ].join(''); } diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts new file mode 100644 index 00000000..7db262c2 --- /dev/null +++ b/src/lib/date-format.ts @@ -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', + }); +} diff --git a/src/lib/download-filename.ts b/src/lib/download-filename.ts new file mode 100644 index 00000000..9337cd7d --- /dev/null +++ b/src/lib/download-filename.ts @@ -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 = { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 4ca50164..7ad9a498 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -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 { - 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 { - 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 { - const dest = new File(Paths.cache, safeFilename(subject)); +export async function shareEmailEml( + blobId: string, + email?: Email | null, + subjectFallback?: string, +): Promise { + 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())) { diff --git a/src/lib/eml-import.ts b/src/lib/eml-import.ts new file mode 100644 index 00000000..7fc077b9 --- /dev/null +++ b/src/lib/eml-import.ts @@ -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 { + 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 { + const bytes = await new File(uri).bytes(); + if (isZip(name, mime)) return extractEmlsFromZip(bytes); + return [{ name, bytes }]; +} diff --git a/src/lib/network-error.ts b/src/lib/network-error.ts new file mode 100644 index 00000000..1364cde2 --- /dev/null +++ b/src/lib/network-error.ts @@ -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)); +} diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts index 563e9520..a675382f 100644 --- a/src/lib/offline-sync.ts +++ b/src/lib/offline-sync.ts @@ -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 { @@ -77,6 +79,7 @@ export async function runOfflineSync(opts: RunOptions): Promise { 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 { 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, diff --git a/src/lib/sieve/__tests__/external-rules.test.ts b/src/lib/sieve/__tests__/external-rules.test.ts new file mode 100644 index 00000000..52fd3d76 --- /dev/null +++ b/src/lib/sieve/__tests__/external-rules.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/src/lib/sieve/__tests__/fixtures/mixed-origins.sieve b/src/lib/sieve/__tests__/fixtures/mixed-origins.sieve new file mode 100644 index 00000000..6b9bc50c --- /dev/null +++ b/src/lib/sieve/__tests__/fixtures/mixed-origins.sieve @@ -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; +} diff --git a/src/lib/sieve/__tests__/generator.test.ts b/src/lib/sieve/__tests__/generator.test.ts new file mode 100644 index 00000000..033adcf6 --- /dev/null +++ b/src/lib/sieve/__tests__/generator.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/src/lib/sieve/__tests__/parser.test.ts b/src/lib/sieve/__tests__/parser.test.ts new file mode 100644 index 00000000..5396fbd2 --- /dev/null +++ b/src/lib/sieve/__tests__/parser.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/src/lib/sieve/generator.ts b/src/lib/sieve/generator.ts new file mode 100644 index 00000000..bc8cfbd6 --- /dev/null +++ b/src/lib/sieve/generator.ts @@ -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 = { + 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(); + 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 { + 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'); +} diff --git a/src/lib/sieve/parser.ts b/src/lib/sieve/parser.ts new file mode 100644 index 00000000..584d599c --- /dev/null +++ b/src/lib/sieve/parser.ts @@ -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 = { + 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; + 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; + return typeof act.type === 'string'; +} + +function isValidRule(rule: unknown): rule is FilterRule { + if (!rule || typeof rule !== 'object') return false; + const r = rule as Record; + 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((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: " 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: ` 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; +} diff --git a/src/lib/sieve/types.ts b/src/lib/sieve/types.ts new file mode 100644 index 00000000..925d15be --- /dev/null +++ b/src/lib/sieve/types.ts @@ -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; +} diff --git a/src/lib/totp.ts b/src/lib/totp.ts new file mode 100644 index 00000000..895d1af2 --- /dev/null +++ b/src/lib/totp.ts @@ -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 }; +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index ba70d42d..49304daa 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -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; }; diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 9b4d3c43..1dfda9ac 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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(initialTo); diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 129c04fb..2ef05d05 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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({ {threadCount} )} - {formatRelativeTime(new Date(item.receivedAt))} + {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })} @@ -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(null); // Batch (multi-select) sheets. @@ -557,6 +595,18 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {currentMailbox?.name ?? 'Inbox'} + { void handleImport(); }} + style={styles.headerButton} + disabled={importing} + hitSlop={6} + > + {importing ? ( + + ) : ( + + )} + { try { - await shareEmailEml(blobId, subject); + await shareEmailEml(blobId, undefined, subject); } catch (e) { Alert.alert('Share failed', e instanceof Error ? e.message : String(e)); } diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 8e84d8c4..54b05a9f 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -54,7 +54,7 @@ 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 { emailId, subject: subjectParam, jmapAccountId } = route.params; const insets = useSafeAreaInsets(); const { width: windowWidth } = useWindowDimensions(); const getEmailDetail = useEmailStore((s) => s.getEmailDetail); @@ -97,9 +97,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setDownloadingBlobId(blobId); try { if (mailAttachmentAction === 'download') { - await downloadAttachment(blobId, name, type); + await downloadAttachment(blobId, name, type, email); } else { - await shareAttachment(blobId, name, type); + await shareAttachment(blobId, name, type, email); } } catch (e) { Alert.alert('Download failed', e instanceof Error ? e.message : String(e)); @@ -107,7 +107,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setDownloadingBlobId(null); } }, - [downloadingBlobId, mailAttachmentAction], + [downloadingBlobId, mailAttachmentAction, email], ); const renderAttachments = () => { @@ -179,16 +179,16 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setError(null); void (async () => { try { - const fetched = await getEmailDetail(emailId); + const fetched = await getEmailDetail(emailId, jmapAccountId); if (cancelled) return; setEmail(fetched); if (fetched && !fetched.keywords?.$seen) { if (markAsReadDelay > 0) { readTimer = setTimeout(() => { - if (!cancelled) void markRead(emailId); + if (!cancelled) void markRead(emailId, jmapAccountId); }, markAsReadDelay); } else { - void markRead(emailId); + void markRead(emailId, jmapAccountId); } } } catch (e) { @@ -203,7 +203,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { cancelled = true; if (readTimer) clearTimeout(readTimer); }; - }, [emailId, getEmailDetail, markRead, markAsReadDelay]); + }, [emailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay]); const starred = !!email?.keywords?.$flagged; const unread = !!email && !email.keywords?.$seen; @@ -306,6 +306,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { cc: email.cc, subject: email.subject ?? '', body: plainTextBody(email), + receivedAt: email.receivedAt, inReplyTo: email.id, }, }); @@ -508,7 +509,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)); } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bcbe3f33..b5941e68 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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>> = { templates: TemplateSettings, folders: FolderSettings, keywords: KeywordSettings, + downloads: DownloadsSettings, security: AccountSecuritySettings, encryption: SmimeSettings, content_senders: ContentSendersSettings, diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index f452e4fa..9b4b0929 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -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([]); const [errors, setErrors] = React.useState>({}); @@ -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) { {starred && } {item.hasAttachment && } - {formatRelativeTime(new Date(item.receivedAt))} + {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })} {item.subject || '(no subject)'} @@ -123,6 +122,13 @@ export default function UnifiedInboxScreen({ navigation }: Props) { {acc.email || acc.username} )} + {item.isShared && ( + + + {item.sharedLabel || 'Shared'} + + + )} {item.preview ? ( {item.preview} @@ -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 }, }); } diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 70a12297..620f80a4 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -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 Promise>; + 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) => { + if (onlineRun) await onlineRun(); + else await Promise.all(ops.map(runOp)); + return { queued: false }; + }; + return { + applyOrQueueBatch, + applyOrQueue: async (op: any, onlineRun?: () => Promise) => 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), }), }, })); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts new file mode 100644 index 00000000..f7d4af6d --- /dev/null +++ b/src/stores/__tests__/outbox-store.test.ts @@ -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); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index beb3893d..21129a39 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -5,6 +5,7 @@ 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 { @@ -55,6 +56,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 +74,7 @@ function clearAccountFeatureStores(accountId: string | null): void { // follow-up. useContactsStore.getState().reset(); useCalendarStore.getState().reset(); + useFilterStore.getState().clearState(); } function refetchFeatureStores(): void { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 528818ec..c9bf20c5 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -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; mailboxIds?: Record }): 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 | undefined, + fromMailboxId: string | null, + toMailboxId: string, +): Record { + const next: Record = {}; + 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; loadMoreEmails: () => Promise; refreshEmails: () => Promise; + importEmails: ( + files: { uri: string; name: string; mimeType?: string }[], + mailboxId: string, + ) => Promise<{ imported: number; failed: number }>; handleStateChange: (change: StateChange) => Promise; - getEmailDetail: (id: string) => Promise; - markRead: (emailId: string) => Promise; + getEmailDetail: (id: string, accountId?: string) => Promise; + markRead: (emailId: string, accountId?: string) => Promise; markUnread: (emailId: string) => Promise; toggleStar: (emailId: string, starred: boolean) => Promise; togglePin: (emailId: string, pinned: boolean) => Promise; @@ -347,6 +376,11 @@ export const useEmailStore = create()( // 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()( pendingUndo: null, }); void useOfflineCacheStore.getState().setAccount(null); + void useOutboxStore.getState().setAccount(null); } else { set({ accountSnapshots: rest }); } @@ -393,6 +428,7 @@ export const useEmailStore = create()( loading: false, }); void useOfflineCacheStore.getState().setAccount(null); + void useOutboxStore.getState().setAccount(null); }, fetchMailboxes: async () => { @@ -572,6 +608,38 @@ export const useEmailStore = create()( } }, + 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()( 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()( } }, - 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()( } 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()( } 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()( 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()( 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()( 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()( 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()( 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()( 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()( (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()( 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()( 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; diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts new file mode 100644 index 00000000..8065bc8a --- /dev/null +++ b/src/stores/filter-store.ts @@ -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; + saveFilters: () => Promise; + validateScript: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>; + addRule: (rule: FilterRule) => void; + updateRule: (ruleId: string, updates: Partial) => 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()((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: [], + }), +})); diff --git a/src/stores/offline-cache-store.ts b/src/stores/offline-cache-store.ts index 5b909d0b..84694640 100644 --- a/src/stores/offline-cache-store.ts +++ b/src/stores/offline-cache-store.ts @@ -69,7 +69,14 @@ interface OfflineCacheState { has: (id: string) => boolean; get: (id: string) => Promise; put: (email: Email, approxBytes: number) => Promise; + // 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>) => Promise; remove: (ids: string[]) => Promise; + // 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; clearAll: () => Promise; // Returns cached emails whose `mailboxIds` include the given mailbox, @@ -200,6 +207,21 @@ export const useOfflineCacheStore = create((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((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; diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts new file mode 100644 index 00000000..570d9e90 --- /dev/null +++ b/src/stores/outbox-store.ts @@ -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 } + | { kind: 'mailboxes'; emailId: string; mailboxIds: Record } + | { 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; + hydrate: () => Promise; + enqueue: (op: OutboxOp) => void; + count: () => number; + pendingForEmail: (emailId: string) => OutboxEntry[]; + flush: () => Promise; + clear: () => Promise; +} + +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 { + 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 { + 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((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, +): Promise { + 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): Promise { + return applyOrQueueBatch([op], onlineRun); +} diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index e73bd488..2f2acd5b 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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'; @@ -25,12 +25,19 @@ 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 +58,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; @@ -135,15 +150,30 @@ interface PersistedSettings { // Plugins (UI-level enable map) pluginEnabled: Record; + // 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, @@ -219,8 +249,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 {