mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c82130267 | ||
|
|
f228257ec9 | ||
|
|
a02e7f8a49 | ||
|
|
ea1f53f550 | ||
|
|
4e035f3011 | ||
|
|
e04bea451a | ||
|
|
5cb28f5d5d | ||
|
|
94c9a2e7a2 | ||
|
|
3f2dfe83ee | ||
|
|
99a20777b7 | ||
|
|
96272b6e17 | ||
|
|
8e33808ded | ||
|
|
2abea9742e | ||
|
|
c705227611 | ||
|
|
5fe8e407c7 | ||
|
|
0159d6acc8 | ||
|
|
8fd709d308 | ||
|
|
0205aa0e1c | ||
|
|
09c24fa805 | ||
|
|
86c3ab9f28 | ||
|
|
9398b46795 | ||
|
|
f9ae4423e9 | ||
|
|
8e1d03c746 | ||
|
|
a222e89611 | ||
|
|
26786ca3e4 | ||
|
|
6ae21d5690 | ||
|
|
d6670c2b60 | ||
|
|
d2ed27fd3b | ||
|
|
ee3b3ffcc3 | ||
|
|
0f80e98fbc | ||
|
|
2b44936a17 | ||
|
|
bf92a00199 | ||
|
|
00fd0696b2 | ||
|
|
c659b5489c | ||
|
|
3a8264c7f7 | ||
|
|
dc3ac4c7ed | ||
|
|
ae31a71e01 | ||
|
|
0e63d45599 | ||
|
|
22697fd111 | ||
|
|
fe45a444d3 | ||
|
|
bea3fc4c42 | ||
|
|
5b72c4d314 | ||
|
|
3194b5ece1 | ||
|
|
f07e039ef0 | ||
|
|
8206893860 | ||
|
|
65c73d82a1 | ||
|
|
1d1ff35d1b | ||
|
|
18e56163de | ||
|
|
3ae9bbe702 | ||
|
|
5802caeea9 | ||
|
|
06742ef7d5 | ||
|
|
dae5700688 | ||
|
|
42ab246cb9 | ||
|
|
2bf2e89813 | ||
|
|
ddae1e63f2 | ||
|
|
490355c65e | ||
|
|
1f2df5f079 | ||
|
|
2586eccaca | ||
|
|
7f9f5af7e6 | ||
|
|
78532211f3 | ||
|
|
240471764a | ||
|
|
63bf3921b0 | ||
|
|
56f8dceb8f | ||
|
|
0d94d7643c | ||
|
|
99eb525d22 | ||
|
|
58e3ef6827 | ||
|
|
4bebf70482 | ||
|
|
0e514659be | ||
|
|
78114b620a | ||
|
|
bc4c57e734 | ||
|
|
358bdb93ea | ||
|
|
8bf48f6465 | ||
|
|
431facef0b | ||
|
|
3d0d440673 | ||
|
|
8deff66274 | ||
|
|
a071d0581c | ||
|
|
6f747db858 | ||
|
|
69bedf0b26 | ||
|
|
ce97232eb2 | ||
|
|
516c9c931a | ||
|
|
c8be383c05 | ||
|
|
9ae91a6ab6 | ||
|
|
c0e1d6b0a8 | ||
|
|
a0ce9258cd | ||
|
|
1cef53af5a | ||
|
|
9800ad07c2 | ||
|
|
3fd5f10afe | ||
|
|
edc26ce1ca | ||
|
|
032732cea1 | ||
|
|
b52768e6fb | ||
|
|
e70e57bf47 | ||
|
|
f6f26f7950 | ||
|
|
00faceb25f | ||
|
|
7f956d60f7 | ||
|
|
5dc40706fa | ||
|
|
26a34d5900 | ||
|
|
580e010b33 | ||
|
|
ccbe67c1bc | ||
|
|
506a4b1a84 | ||
|
|
b538a0e53d | ||
|
|
58a6f45c15 | ||
|
|
feb4affea6 | ||
|
|
3ffcef2ec1 | ||
|
|
990cd84ee3 | ||
|
|
0b1c240802 | ||
|
|
2c0dbd15d1 | ||
|
|
161baf1708 | ||
|
|
1c6aa680c5 | ||
|
|
e650cb2d0a | ||
|
|
eb1dd6c844 | ||
|
|
fb4796e188 | ||
|
|
b84d4d8e9e | ||
|
|
eaa8249e76 | ||
|
|
a63ba7cb81 | ||
|
|
beec3322b8 | ||
|
|
21bc02709a | ||
|
|
32b5298cdd | ||
|
|
da1ff62725 | ||
|
|
077b590851 | ||
|
|
71af9d105d | ||
|
|
82b97fada4 | ||
|
|
428a1bf03d | ||
|
|
09350dd1f0 | ||
|
|
d2d13a37c9 | ||
|
|
be82bfe814 | ||
|
|
5c775ad82e | ||
|
|
941e5ede12 | ||
|
|
2577f4e64b | ||
|
|
ee21c15fec | ||
|
|
e574fd9e11 | ||
|
|
dfa278f7a8 | ||
|
|
4eeda34d95 | ||
|
|
22ad291202 | ||
|
|
f040a48e2f | ||
|
|
c29cbcd549 | ||
|
|
bf67b97176 | ||
|
|
93761e67ef | ||
|
|
a2484f3508 | ||
|
|
82997e27e4 | ||
|
|
b7995fafde | ||
|
|
cde461999f | ||
|
|
802e1e1c71 | ||
|
|
2427bdbd79 | ||
|
|
05e2af23dd | ||
|
|
42f18a78b2 | ||
|
|
406d2c208a | ||
|
|
6f712bf43f | ||
|
|
844cb703c8 | ||
|
|
11e5963149 | ||
|
|
07438e010e | ||
|
|
3e33efb71f | ||
|
|
376ee3d678 | ||
|
|
6f19cdbd17 | ||
|
|
6985a7d5f6 | ||
|
|
8231a55acb | ||
|
|
1b0740eab4 | ||
|
|
c5e509274d | ||
|
|
1bf5920b04 | ||
|
|
2547c6f199 | ||
|
|
69e27a386c | ||
|
|
7390096567 | ||
|
|
58c642f27b | ||
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f | ||
|
|
b92f85a236 | ||
|
|
643d704b74 | ||
|
|
9e383c3616 | ||
|
|
1543db49d1 |
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
@@ -0,0 +1,274 @@
|
||||
name: Release iOS (TestFlight)
|
||||
|
||||
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
|
||||
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
|
||||
# any native iOS change in app.config.js (or a config plugin), never in ios/.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_to_testflight:
|
||||
description: "Upload the build to TestFlight (off = build the IPA only)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-ios-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
# marketing version, so every run needs a fresh, increasing number.
|
||||
IOS_BUILD_NUMBER: ${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Show toolchain versions
|
||||
run: |
|
||||
xcodebuild -version
|
||||
pod --version
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods/repos
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
|
||||
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
|
||||
echo "Using workspace $WORKSPACE, scheme $SCHEME"
|
||||
|
||||
# ── Unsigned path: a compile check only, so the workflow is still useful
|
||||
# on forks and before the Apple secrets exist.
|
||||
- name: Build without signing
|
||||
if: steps.creds.outputs.signed != 'true'
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY=""
|
||||
|
||||
# ── Signed path ────────────────────────────────────────────
|
||||
- name: Import the distribution certificate
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
|
||||
# Ephemeral runner, ephemeral keychain - this password protects nothing
|
||||
# beyond the life of the job.
|
||||
KEYCHAIN_PASSWORD=$(uuidgen)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
|
||||
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
|
||||
rm -f "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Without this, codesign triggers an interactive keychain prompt that
|
||||
# nothing can answer on a headless runner.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
|
||||
# Put our keychain first in the search list, keeping the login one so
|
||||
# Xcode can still reach Apple's intermediate certificates.
|
||||
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
|
||||
security find-identity -v -p codesigning "$KEYCHAIN"
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
# altool looks the key up by ID in this directory.
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Manual signing, deliberately. Automatic signing always signs the
|
||||
# archive with a *development* identity and relies on -exportArchive to
|
||||
# re-sign it for distribution afterwards - which needs a development
|
||||
# certificate and a registered device that a CI-only TestFlight
|
||||
# pipeline has no reason to own. Naming the App Store profile up front
|
||||
# skips that dance entirely.
|
||||
xcodebuild archive \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
# resolved values instead of guessing twice.
|
||||
- name: Dump signing settings
|
||||
if: failure() && steps.creds.outputs.signed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "── identities in the keychain ──"
|
||||
security find-identity -v -p codesigning || true
|
||||
echo "── resolved build settings ──"
|
||||
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release -showBuildSettings 2>/dev/null \
|
||||
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
|
||||
|
||||
- name: Export the IPA
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store</string>
|
||||
<key>teamID</key><string>${TEAM_ID}</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/export" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
|
||||
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
|
||||
cp "$SRC" "$DEST"
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
with:
|
||||
name: ${{ steps.export.outputs.path }}
|
||||
path: ${{ steps.export.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
if: >-
|
||||
steps.creds.outputs.signed == 'true' &&
|
||||
(github.event_name == 'release' || inputs.upload_to_testflight)
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: |
|
||||
IPA="${{ steps.export.outputs.path }}"
|
||||
# Validate first - it catches missing Info.plist keys and entitlement
|
||||
# mistakes in seconds, instead of via a rejection email 20 minutes later.
|
||||
xcrun altool --validate-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
||||
@@ -7,7 +7,13 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
|
||||
import { startPushUpdates } from './src/api/push';
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
import type { StateChange } from './src/api/types';
|
||||
import { dispatchStateChange } from './src/lib/state-change-bus';
|
||||
import { sweepStaleExportFiles } from './src/lib/email-export';
|
||||
import { useFilterStore } from './src/stores/filter-store';
|
||||
import { useVacationStore } from './src/stores/vacation-store';
|
||||
import {
|
||||
addMessageListener,
|
||||
addNotificationTapListener,
|
||||
@@ -44,6 +50,11 @@ import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||
import { ToastHost } from './src/components/ToastHost';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
@@ -77,6 +88,29 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
});
|
||||
}
|
||||
|
||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
await handleDeepLink(link, {
|
||||
navigation: navigationRef,
|
||||
resolveThreadId: async (emailId) => {
|
||||
try {
|
||||
const [email] = await getEmails([emailId]);
|
||||
return email?.threadId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
switchAccount: async (accountId) => {
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.activeAccountId === accountId) return true;
|
||||
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
||||
await auth.switchAccount(accountId);
|
||||
return useAuthStore.getState().activeAccountId === accountId;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
const c = useColors();
|
||||
return (
|
||||
@@ -101,6 +135,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
<ToastHost />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -273,6 +309,9 @@ export default function App() {
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
// Attachments shared out of the app linger in the cache dir; drop the
|
||||
// ones older than a day so a granted content URI can't read them forever.
|
||||
void sweepStaleExportFiles();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
@@ -294,12 +333,21 @@ export default function App() {
|
||||
});
|
||||
}, [haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
// Foreground FCM messages: the Kotlin service skips the headless task while
|
||||
// the app is visible, so feed the relay's StateChange straight into the
|
||||
// stores. SSE normally beats it, but this covers the window where the SSE
|
||||
// socket is down and the poll fallback has not fired yet.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
console.log('[push] fcm message', payload.title);
|
||||
const raw = payload?.data?.changed;
|
||||
if (!raw) return;
|
||||
try {
|
||||
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
if (!changed || typeof changed !== 'object') return;
|
||||
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
|
||||
} catch {
|
||||
// malformed payload - ignore
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -328,6 +376,52 @@ export default function App() {
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Deep links and share-sheet payloads. The cold-start URL / share is read
|
||||
// once auth is restored so the target screen has credentials.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
const open = (link: DeepLink | null) => {
|
||||
if (!link || cancelled) return;
|
||||
// Give the navigator a tick to mount after the auth gate flips.
|
||||
setTimeout(() => { void openDeepLink(link); }, 50);
|
||||
};
|
||||
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
|
||||
void getInitialShare().then((share) => {
|
||||
if (!share) return;
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length) {
|
||||
navigationRef.isReady() && navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
|
||||
const shareSub = addShareListener((share) => {
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
|
||||
navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
urlSub.remove();
|
||||
shareSub();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
@@ -377,45 +471,133 @@ export default function App() {
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
// Live updates (SSE with polling fallback), re-armed on every account
|
||||
// switch and every re-established session — the singleton `client` object
|
||||
// never changes identity, so it cannot be the dependency on its own.
|
||||
// Backgrounding closes the stream (no socket + server pings while the app
|
||||
// is asleep); foregrounding reconnects it and refreshes what the user is
|
||||
// looking at, since events that happened in between are gone for good.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
if (!isAuthenticated || !client || !haveLiveSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let handle: LiveUpdatesHandle | null = null;
|
||||
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
|
||||
|
||||
void (async () => {
|
||||
const onStateChange = async (change: StateChange) => {
|
||||
// Filters / vacation edited elsewhere (webmail, another device): refetch
|
||||
// so the settings panes don't save stale state over the newer script.
|
||||
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
|
||||
const own = primary ? change.changed?.[primary] : undefined;
|
||||
const extra: Promise<unknown>[] = [];
|
||||
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
|
||||
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
|
||||
dispatchStateChange(change);
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
...extra,
|
||||
]);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const stopPushUpdates = await startPushUpdates(client, {
|
||||
onStateChange: async (change) => {
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn(error.message);
|
||||
},
|
||||
const next = await startLiveUpdates({
|
||||
onStateChange: (change) => { void onStateChange(change); },
|
||||
onError: (error) => { console.warn('[push]', error.message); },
|
||||
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
|
||||
isActive: () => appActive,
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
stopPushUpdates();
|
||||
next.close();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = stopPushUpdates;
|
||||
handle = next;
|
||||
} catch (error) {
|
||||
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const refreshAfterResume = () => {
|
||||
const email = useEmailStore.getState();
|
||||
void email.fetchMailboxes();
|
||||
if (email.currentMailboxId) void email.refreshEmails();
|
||||
void useOutboxStore.getState().flush();
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
const nowActive = state === 'active';
|
||||
if (nowActive === appActive) return;
|
||||
appActive = nowActive;
|
||||
if (!nowActive) {
|
||||
handle?.close();
|
||||
handle = null;
|
||||
return;
|
||||
}
|
||||
// Coming back: verify the session is still alive before trusting the
|
||||
// stream, then reconnect and catch up on what was missed.
|
||||
void (async () => {
|
||||
const alive = await jmapClient.ping().catch(() => false);
|
||||
if (!mounted) return;
|
||||
if (!alive && useNetworkStore.getState().online) {
|
||||
const ok = await useAuthStore.getState().retrySession().catch(() => false);
|
||||
if (!ok || !mounted) return;
|
||||
}
|
||||
if (!handle) await start();
|
||||
else handle.reconnect();
|
||||
refreshAfterResume();
|
||||
})();
|
||||
});
|
||||
|
||||
// Reconnect when the network comes back while foregrounded.
|
||||
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && appActive) {
|
||||
if (handle) handle.reconnect();
|
||||
else void start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
cleanup?.();
|
||||
subscription.remove();
|
||||
unsubscribeNetwork();
|
||||
handle?.close();
|
||||
handle = null;
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
|
||||
|
||||
// Foreground keep-alive: a `Core/echo` every 30 s tells us when the server
|
||||
// is unreachable even though the device is online (NetInfo cannot), and
|
||||
// drives the per-account connection dot.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !haveLiveSession || !activeAccountId) return;
|
||||
let cancelled = false;
|
||||
let failures = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (AppState.currentState !== 'active') return;
|
||||
if (!useNetworkStore.getState().online) return;
|
||||
void jmapClient.ping().then((ok) => {
|
||||
if (cancelled) return;
|
||||
failures = ok ? 0 : failures + 1;
|
||||
const account = useAccountStore.getState().getAccountById(activeAccountId);
|
||||
if (!account) return;
|
||||
// One missed echo can be a blip; two in a row is a lost connection.
|
||||
const connected = failures < 2;
|
||||
if (account.isConnected !== connected) {
|
||||
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [isAuthenticated, haveLiveSession, activeAccountId]);
|
||||
|
||||
// Skip the "Restoring session" flash for returning users: if we already
|
||||
// have a persisted active account, render the main UI immediately with
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Webmail parity checklist
|
||||
|
||||
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
|
||||
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
|
||||
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
|
||||
behaviour, and every webmail changelog fix since the native app was started
|
||||
(1.4.5 → 1.9.2), was checked against the native code.
|
||||
|
||||
The audit was done by reading code on both sides, not by running the app. Every
|
||||
finding cites `file:line` on both sides so it can be re-verified quickly; a
|
||||
sample of the 32 P1 findings was additionally spot-checked by hand.
|
||||
|
||||
## How to use this document
|
||||
|
||||
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
|
||||
Tick the `- [ ]` boxes there as you fix things; this file is the index.
|
||||
- Each finding has a **priority** and a **category**:
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
|
||||
| `P2` | notable feature missing or visibly wrong |
|
||||
| `P3` | polish, nice-to-have, minor divergence |
|
||||
|
||||
| Category | Meaning |
|
||||
|---|---|
|
||||
| `missing` | webmail has it, native does not |
|
||||
| `partial` | native has a reduced version |
|
||||
| `bug` | native has it, but it is wrong |
|
||||
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
|
||||
| `rn-only-bug` | a bug that only exists in native (found while reading) |
|
||||
|
||||
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
|
||||
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
|
||||
"native #NN".
|
||||
- Each area file ends with **Verified at parity** (do not redo) and
|
||||
**N/A on mobile** (deliberately out of scope) lists.
|
||||
- A few findings appear in two areas because they sit on a boundary (e.g.
|
||||
"forward drops attachments" is in both viewer and composer). Fix once, tick
|
||||
both.
|
||||
|
||||
## Baseline health of this repo (2026-08-29)
|
||||
|
||||
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
|
||||
- `src/stores/__tests__/auth-store.test.ts` fails to load with
|
||||
`SyntaxError: Unexpected token 'typeof'`. The chain
|
||||
`auth-store → email-store → outbox-store → network-store` imports
|
||||
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
|
||||
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
|
||||
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
|
||||
import was added; `email-store.test.ts` only passes because it mocks
|
||||
`../outbox-store` itself.
|
||||
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
|
||||
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
|
||||
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
|
||||
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
|
||||
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
|
||||
- `npx tsc --noEmit` is clean.
|
||||
- Uncommitted work in the tree at audit time: ascending/descending mail sort
|
||||
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
|
||||
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
|
||||
Findings that touch list ordering assume that lands.
|
||||
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
|
||||
themes, file storage – UI stubs only". Filters, Files and file sharing are
|
||||
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
|
||||
stubs (area 08).
|
||||
|
||||
## Status after the 2026-08-29 fix pass
|
||||
|
||||
Worked down the same day, area by area, by one orchestrating session plus one
|
||||
agent per area, each change committed on its own (`type: short description`).
|
||||
Per-area counts of ticked items are in the area files; every tick carries the
|
||||
commit hash. The remaining `- [ ]` items are either deferred with a note
|
||||
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
|
||||
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
|
||||
store for native #1).
|
||||
|
||||
## Areas
|
||||
|
||||
| # | Area | File | Items | P1 | P2 | P3 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 4 | 8 | 18 |
|
||||
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 1 | 20 | 33 |
|
||||
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 6 | 13 | 26 |
|
||||
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 5 | 14 | 32 |
|
||||
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 51 | 5 | 20 | 26 |
|
||||
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 51 | 1 | 18 | 31 |
|
||||
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 33 | 3 | 8 | 22 |
|
||||
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 0 | 14 | 32 |
|
||||
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 7 | 23 | 26 |
|
||||
| | **Total** | | **418** | **32** | **138** | **246** |
|
||||
|
||||
Counts are of `- [ ]` items per file (a handful of "verified" notes carry a
|
||||
priority tag too, so P-sums can differ by one or two from the item count).
|
||||
|
||||
## All P1 findings (fix these first)
|
||||
|
||||
### Data loss / wrong send
|
||||
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
|
||||
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
|
||||
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
|
||||
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
|
||||
|
||||
### Core flow blocked / wrong content shown
|
||||
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
|
||||
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
|
||||
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
|
||||
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
|
||||
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
|
||||
|
||||
### JMAP client, live sync, security
|
||||
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
|
||||
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
|
||||
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
|
||||
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
|
||||
|
||||
### Repo health
|
||||
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
|
||||
|
||||
## Native issues mapped to findings
|
||||
|
||||
| Native issue | Where it is explained |
|
||||
|---|---|
|
||||
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
|
||||
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
|
||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
These come up in nearly every area; fixing them centrally pays off more than
|
||||
per-screen patches.
|
||||
|
||||
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
|
||||
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
|
||||
single gap causes the broken threading headers, missing Reply-To handling,
|
||||
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
|
||||
panel. (03, 04)
|
||||
2. **No thread model.** The list collapses threads but the viewer is a
|
||||
single-message pager over the folder page. (02, 03)
|
||||
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
|
||||
84 of 94 screen/component files never call `t()`; calendar, files,
|
||||
vacation, scheduled, login and every settings pane except Language and
|
||||
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
|
||||
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
|
||||
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
|
||||
vacation and Files are primary-account only; shared/group accounts work
|
||||
for mail folders but not elsewhere. (01, 05, 06, 07)
|
||||
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
|
||||
spam (#850), search defaults to all folders (#788), folder substring dedup
|
||||
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
|
||||
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
|
||||
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
|
||||
calendar/address book creation (#907), `supported-calendar-component-set`
|
||||
pinning (#760), request timeouts (#702), relative session URL resolution.
|
||||
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
|
||||
the settings UI (layout, sound, calendar notifications, sidebar apps,
|
||||
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
|
||||
the toggles. (08, table in that file)
|
||||
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
|
||||
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
|
||||
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
|
||||
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
|
||||
unread", never matches the payload to an account, no device list/revoke,
|
||||
Android/FCM only. (08)
|
||||
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
|
||||
with none of the layers webmail added since 1.4.5: request deadlines,
|
||||
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
|
||||
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
|
||||
first-touch serialisation, and — most importantly — method-error checking.
|
||||
A shared `requireMethodResult` / `assertSetResult` helper used by every
|
||||
`src/api/*.ts` function fixes a whole class of silent failures. (09)
|
||||
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
|
||||
EventSource; reconnect, token refresh, account switch and client-cert
|
||||
users all leave the stream dead or stale. (09)
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
|
||||
2. The P1 list above, roughly in the order given (composer/send correctness,
|
||||
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
|
||||
JMAP client + SSE lifecycle).
|
||||
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
|
||||
checking + request deadline) — they unlock a large slice of the P2 items in
|
||||
03/04/09 and make the outbox trustworthy.
|
||||
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
|
||||
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
|
||||
catalogs + `t()` sweep).
|
||||
5. P3 items opportunistically while touching the same files.
|
||||
|
||||
## Explicitly out of scope on mobile
|
||||
|
||||
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
|
||||
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
|
||||
back/forward and permalinks in the address bar, PWA install / service worker,
|
||||
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
|
||||
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
|
||||
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
|
||||
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
|
||||
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
|
||||
exchange, push relay list) the area file says what the native equivalent
|
||||
would be.
|
||||
|
||||
## Keeping this current
|
||||
|
||||
Re-run the audit after each webmail release: read the new changelog "Fixes"
|
||||
bullets, decide `RN affected / not affected / N/A`, and append to the matching
|
||||
area file. When a finding is fixed, tick it and add the native commit hash in
|
||||
the same line so the next audit can skip it.
|
||||
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -41,6 +41,7 @@ npx expo start
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
@@ -28,6 +28,30 @@
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SENDTO"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="mailto"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/*"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
@@ -141,7 +141,9 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
// Redirects are resolved in JS (lib/client-cert.ts) so a POST
|
||||
// stays a POST and credentials never follow a cross-host hop.
|
||||
instanceFollowRedirects = false
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
|
||||
@@ -66,6 +66,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
|
||||
?: accountId?.let { "bulwark-mail:$it" }
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -74,8 +78,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId,
|
||||
emailId, threadId, subject, accountId, groupKey,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +91,12 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getInitialShare(promise: Promise) {
|
||||
val payload = ShareIntentStore.consume()
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
private fun postNotification(
|
||||
notificationId: String,
|
||||
title: String,
|
||||
@@ -96,6 +107,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
groupKey: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -122,11 +134,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pending)
|
||||
if (groupKey != null) {
|
||||
builder.setGroup(groupKey)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
// One summary per account group so several deliveries collapse into a
|
||||
// single "N new messages" entry (Android renders the "+N more" itself
|
||||
// from the children). Tapping the summary opens the app on the inbox;
|
||||
// the per-message children carry the deep link.
|
||||
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
|
||||
val ctx = reactApplicationContext
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val children = manager.activeNotifications.filter {
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
val line = listOfNotNull(
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
|
||||
).joinToString(" ")
|
||||
if (line.isNotBlank()) inbox.addLine(line)
|
||||
}
|
||||
inbox.setBigContentTitle(groupTitle)
|
||||
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
groupKey.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
||||
.setContentTitle(groupTitle)
|
||||
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
||||
.setStyle(inbox)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
manager.notify(groupKey, groupKey.hashCode(), summary)
|
||||
}
|
||||
|
||||
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
|
||||
val size = 192
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
|
||||
@@ -19,11 +19,15 @@ class MainActivity : ReactActivity() {
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
NotificationTapStore.captureFromIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent)?.let { share ->
|
||||
BulwarkFcmModule.emit("app:share", share.toMap())
|
||||
}
|
||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
|
||||
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
|
||||
// intent (the system share sheet). MainActivity fills this on onCreate /
|
||||
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
|
||||
// live "app:share" event and opens the composer.
|
||||
object ShareIntentStore {
|
||||
@Volatile
|
||||
private var pending: SharePayload? = null
|
||||
|
||||
fun consume(): SharePayload? {
|
||||
val value = pending
|
||||
pending = null
|
||||
return value
|
||||
}
|
||||
|
||||
fun captureFromIntent(intent: Intent?): SharePayload? {
|
||||
val action = intent?.action ?: return null
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (action == Intent.ACTION_SEND) {
|
||||
streamExtra(intent)?.let { uris.add(it) }
|
||||
} else {
|
||||
streamExtras(intent)?.let { uris.addAll(it) }
|
||||
}
|
||||
val type = intent.type
|
||||
|
||||
if (text.isNullOrBlank() && uris.isEmpty()) return null
|
||||
val payload = SharePayload(
|
||||
text = text,
|
||||
subject = subject,
|
||||
uris = uris.map { it.toString() },
|
||||
mimeTypes = uris.map { type ?: "" },
|
||||
)
|
||||
pending = payload
|
||||
// Clear so a later lifecycle event doesn't replay this share.
|
||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||
intent.removeExtra(Intent.EXTRA_SUBJECT)
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
return payload
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtra(intent: Intent): Uri? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtras(intent: Intent): List<Uri>? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
data class SharePayload(
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val uris: List<String>,
|
||||
val mimeTypes: List<String>,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
if (text != null) putString("text", text)
|
||||
if (subject != null) putString("subject", subject)
|
||||
putArray("uris", Arguments.fromList(uris))
|
||||
putArray("mimeTypes", Arguments.fromList(mimeTypes))
|
||||
}
|
||||
}
|
||||
|
||||
// 1 MiB of shared text is plenty; anything bigger is an attachment.
|
||||
private const val MAX_TEXT = 1 * 1024 * 1024
|
||||
}
|
||||
+40
-3
@@ -16,23 +16,42 @@ if (!COMMIT) {
|
||||
}
|
||||
COMMIT = COMMIT.slice(0, 7);
|
||||
|
||||
// App Store Connect rejects a build whose CFBundleVersion it has already seen
|
||||
// for this CFBundleShortVersionString, so this has to advance on every upload
|
||||
// even when VERSION does not. CI passes the workflow run number; local builds
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
// mailto: lets Android/iOS offer the app for mail links in other apps.
|
||||
scheme: ['bulwarkmobile', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'dark',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
splash: {
|
||||
image: './assets/splash-icon.png',
|
||||
resizeMode: 'contain',
|
||||
backgroundColor: '#09090b',
|
||||
backgroundColor: '#ffffff',
|
||||
dark: {
|
||||
image: './assets/splash-icon.png',
|
||||
backgroundColor: '#09090b',
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: 'org.bulwarkmail.mobile',
|
||||
buildNumber: IOS_BUILD_NUMBER,
|
||||
config: {
|
||||
// The app only speaks HTTPS/TLS and uses platform crypto, which is
|
||||
// exempt. Declaring it here skips the manual export-compliance
|
||||
// questionnaire that otherwise blocks every TestFlight build.
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -42,6 +61,11 @@ module.exports = {
|
||||
edgeToEdgeEnabled: true,
|
||||
predictiveBackGestureEnabled: false,
|
||||
package: 'com.anonymous.bulwarkmobile',
|
||||
// The AsyncStorage database holds cached message bodies, the outbox and
|
||||
// the account registry; the platform backup would ship all of it to the
|
||||
// user's Google account. Credentials live in SecureStore (excluded by
|
||||
// its own rules) but the mail cache must not leave the device either.
|
||||
allowBackup: false,
|
||||
},
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
@@ -55,6 +79,19 @@ module.exports = {
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
// QR scanning never records audio; leaving the mic entry in would be
|
||||
// an unexplained permission in App Review.
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{
|
||||
photosPermission:
|
||||
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
|
||||
// Only launchImageLibraryAsync is used - no in-app capture.
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# iOS builds and TestFlight
|
||||
|
||||
The iOS app is built by [.github/workflows/release-ios.yml](../.github/workflows/release-ios.yml)
|
||||
on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner
|
||||
minutes are free for this repository because it is public.
|
||||
|
||||
Unlike `android/`, which is committed and hand-maintained, **`ios/` is gitignored
|
||||
and regenerated by `expo prebuild` on every run.** Never commit it, and never fix
|
||||
anything by editing it - native iOS changes belong in `app.config.js` or a config
|
||||
plugin, or they will be silently thrown away on the next build.
|
||||
|
||||
Without the secrets below the workflow still runs, but only as an unsigned
|
||||
compile check; no IPA is produced.
|
||||
|
||||
## One-time Apple setup
|
||||
|
||||
You need an active Apple Developer Program membership ($99/yr). Everything here
|
||||
can be done from Windows - no Mac required.
|
||||
|
||||
### 1. Team ID
|
||||
|
||||
developer.apple.com → Account → Membership details. A 10-character string like
|
||||
`A1B2C3D4E5`.
|
||||
|
||||
### 2. Distribution certificate
|
||||
|
||||
Apple issues certificates against a certificate signing request. Keychain Access
|
||||
is the usual way to make one, but OpenSSL works and does not need a Mac:
|
||||
|
||||
```bash
|
||||
openssl genrsa -out dist.key 2048
|
||||
openssl req -new -key dist.key -out dist.csr \
|
||||
-subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"
|
||||
```
|
||||
|
||||
Upload `dist.csr` at developer.apple.com → Certificates → **+** → **Apple
|
||||
Distribution**, download the resulting `distribution.cer`, then bundle the cert
|
||||
and its private key into a `.p12`:
|
||||
|
||||
```bash
|
||||
openssl x509 -inform DER -in distribution.cer -out dist.pem
|
||||
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
|
||||
-passout pass:CHOOSE_A_PASSWORD
|
||||
```
|
||||
|
||||
**On OpenSSL 3.x, add `-legacy` to that last command.** OpenSSL 3 defaults to
|
||||
AES-256-CBC, which macOS `security import` refuses to read, and the workflow then
|
||||
fails at the import step. OpenSSL 1.x has no `-legacy` flag and already writes
|
||||
the format macOS wants - passing it there is an error. Check with `openssl
|
||||
version` first; Git Bash on Windows currently ships 1.1.1.
|
||||
|
||||
Keep `dist.key` somewhere safe - you cannot re-download it, and losing it means
|
||||
revoking the certificate and starting over.
|
||||
|
||||
### 3. App Store Connect API key
|
||||
|
||||
App Store Connect → Users and Access → Integrations → App Store Connect API →
|
||||
**+**, with the **App Manager** role. Download the `.p8` (**one download only**)
|
||||
and note the **Key ID** and the **Issuer ID** shown above the key list.
|
||||
|
||||
The key authenticates the TestFlight upload.
|
||||
|
||||
### 4. App ID, then app record
|
||||
|
||||
These live on two different Apple sites and have to be done in this order.
|
||||
|
||||
**developer.apple.com** → Certificates, Identifiers & Profiles → Identifiers →
|
||||
**+** → App IDs → App. Explicit bundle ID `org.bulwarkmail.mobile`, matching
|
||||
`ios.bundleIdentifier` in [app.config.js](../app.config.js). Leave every
|
||||
capability off: push is not implemented on iOS, and sign-in uses the custom
|
||||
`bulwarkmobile://` scheme rather than universal links, so no Associated Domains.
|
||||
|
||||
**appstoreconnect.apple.com** → Apps → **+** → New App. Platform iOS, any SKU.
|
||||
The bundle ID here is a dropdown populated from the identifiers above, which is
|
||||
why the App ID has to be registered first. The app name must be unique across
|
||||
the entire App Store.
|
||||
|
||||
The TestFlight upload is rejected if the app record does not exist - and that
|
||||
failure lands at the very end of a ~30 minute build.
|
||||
|
||||
### 5. App Store provisioning profile
|
||||
|
||||
developer.apple.com → Profiles → **+** → Distribution → **App Store Connect**.
|
||||
Pick the App ID from step 4 and the distribution certificate from step 2, name it
|
||||
`Bulwark Mobile App Store`, and download the `.mobileprovision`.
|
||||
|
||||
The build signs **manually** and this profile is not optional. Automatic signing
|
||||
would be less to maintain, but `xcodebuild archive` under automatic signing always
|
||||
signs with a *development* identity and leans on `-exportArchive` to re-sign for
|
||||
distribution afterwards. That needs a development certificate and at least one
|
||||
registered device - neither of which a CI-only TestFlight pipeline has any reason
|
||||
to own, and Apple will not issue a development profile without a device. Naming
|
||||
the App Store profile up front avoids the whole problem.
|
||||
|
||||
The profile expires when the certificate does (2027-08-01). Regenerate it here and
|
||||
update the secret when that happens.
|
||||
|
||||
## Repository secrets
|
||||
|
||||
Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_TEAM_ID` | The 10-character team ID from step 1 |
|
||||
| `IOS_DIST_CERT_BASE64` | `dist.p12` from step 2, base64-encoded |
|
||||
| `IOS_DIST_CERT_PASSWORD` | The password you chose for `dist.p12` |
|
||||
| `APP_STORE_CONNECT_KEY_ID` | Key ID from step 3 |
|
||||
| `APP_STORE_CONNECT_ISSUER_ID` | Issuer ID from step 3 |
|
||||
| `APP_STORE_CONNECT_PRIVATE_KEY` | The `.p8` from step 3, base64-encoded |
|
||||
| `IOS_PROVISIONING_PROFILE_BASE64` | The `.mobileprovision` from step 5, base64-encoded |
|
||||
|
||||
To base64-encode a file:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash
|
||||
base64 -w0 dist.p12
|
||||
```
|
||||
|
||||
## Running a build
|
||||
|
||||
Publishing a GitHub release triggers the iOS workflow alongside the Android one.
|
||||
You can also run it by hand from the Actions tab, with an
|
||||
`upload_to_testflight` toggle if you only want the IPA as a workflow artifact.
|
||||
|
||||
The IPA is deliberately not attached to the GitHub release: unlike the APK, it is
|
||||
not installable outside TestFlight.
|
||||
|
||||
`CFBundleVersion` comes from the workflow run number via the `IOS_BUILD_NUMBER`
|
||||
env var. App Store Connect rejects a build number it has already seen for a given
|
||||
version, so never re-run a workflow expecting a re-upload to succeed - the run
|
||||
number advances on its own, so just start a new run.
|
||||
|
||||
## After the upload
|
||||
|
||||
Processing takes roughly 5-30 minutes before the build shows up in TestFlight.
|
||||
Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
|
||||
and an equivalent native module; until then `push-notifications.ts` short-
|
||||
circuits and the app polls like a device with push disabled.
|
||||
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
|
||||
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
|
||||
not connect.
|
||||
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
|
||||
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
|
||||
Downloading and installing a binary is forbidden by App Store Review guideline
|
||||
2.5.2, so iOS updates come through TestFlight instead. The Updates settings
|
||||
pane is removed from the list on iOS; the version and commit are still shown
|
||||
under About & Data.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Authentication, login, session & multi-account
|
||||
|
||||
## Summary
|
||||
RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pairing, per-account keychain credentials, restore/switch/logout, Stalwart account-security API) and several WEB fixes are already mirrored (refresh coalescing, cached-token reuse, keep-session-on-network-error). The biggest gaps are: no TOTP/2FA password login at all (and the webmail handoff silently breaks for TOTP accounts), the Account Security screen is dead against every Stalwart server because it checks the wrong capability map (native issue #47), a failed "add account" or failed switch destroys the live session, expired/revoked tokens never lead back to the login screen, account display names are never refreshed (#900), relative session URLs are not resolved, and there is no per-account remove / per-device push revoke / public-key management.
|
||||
|
||||
## Findings
|
||||
|
||||
### Login flow
|
||||
|
||||
- [x] **TOTP / 2FA password login is impossible** — `P1` — `missing` — fixed in b52768e
|
||||
- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required", `lib/jmap/client.ts:956-966` → `TOTP_REQUIRED`). With a code it calls `/api/auth/totp-token-exchange` which performs Stalwart's structured login `POST {server}/api/auth {type:'authCode', accountName, accountSecret, mfaToken, clientId, redirectUri, codeChallenge, codeChallengeMethod:'S256'}` then `POST {server}/auth/token` (authorization_code + PKCE) and continues as a bearer session (`app/api/auth/totp-token-exchange/route.ts:26-28, 90-93, 125, 150`; `stores/auth-store.ts:651-706`; changelog 1.7.5 "Support MFA login via the structured auth endpoint"). Legacy `password$totp` basic-auth fallback for pre-0.16 servers (`auth-store.ts:696-702`).
|
||||
- What RN does: `JMAPClient.connect` only does HTTP Basic with the raw password (`src/api/jmap-client.ts:88-106`, `363-383`). A 402 becomes `"Session discovery failed: 402 …"`, which `describeLoginError` only maps for 403/404 (`src/lib/login-errors.ts:57`) so the user sees a generic "Sign-in failed". The 401 copy tells 2FA users to create an app password (`login-errors.ts:39-44`) — that is the only workaround today.
|
||||
- Fix hint: detect 402 with a title containing `totp`/`mfa` in `fetchSession`, show a code step, then implement the two-step exchange directly on device (no CORS on native): `/api/auth` with `mfaToken` + `clientId: 'bulwark-webmail'` + PKCE, then `/auth/token`; persist the result as an OAuth bundle (`tokenEndpoint = ${server}/auth/token`, `clientId`) so `connectWithOAuth` and the existing refresh path take over.
|
||||
|
||||
- [x] **Webmail password handoff breaks for TOTP-protected accounts** — `P1` — `bug` (cross-repo) — fixed in 0d94d76 (RN retries the handed-off password with a code prompt; the webmail-side flow=oauth hand-off is still open)
|
||||
- What WEB does: in mobile-handoff mode the login page always hands back `flow=password` with the raw password the user typed (`app/(main)/[locale]/login/page.tsx:638-642, 282`) even when the webmail itself upgraded that login to token auth via TOTP (`stores/auth-store.ts:651-706`, `upgradedToOAuth`).
|
||||
- What RN does: `loginViaWebmail` feeds the password into `login()` → Basic auth (`src/stores/auth-store.ts:226-229`) → Stalwart 402 → "Sign-in failed". The user completed 2FA in the browser and still cannot sign in.
|
||||
- Fix hint: WEB should hand off `flow=oauth` (access/refresh token, `token_endpoint`, `client_id`) when the password login was TOTP-upgraded (the mobile SSO branch in `app/api/auth/sso/complete/route.ts:66-97` already returns that shape); RN needs no change once WEB does that. Alternatively RN implements the previous finding and retries with a TOTP prompt when the handed-off password is rejected with 402.
|
||||
|
||||
- [x] **No direct OAuth/OIDC (PKCE) against the mail server — a Bulwark webmail at the JMAP origin is assumed** — `P2` — `missing` — fixed in 4bebf70
|
||||
- What WEB does: discovers `/.well-known/oauth-authorization-server` or `openid-configuration` (`lib/oauth/discovery.ts:150-175`), runs client-side PKCE (`login/page.tsx:569-621`, `lib/oauth/pkce.ts`) or server-side SSO; works against Stalwart's built-in OAuth (default client id `bulwark-webmail`, `lib/oauth/token-exchange.ts:14`), Keycloak, Authentik.
|
||||
- What RN does: `ConfirmStep → runHandoff(serverUrl)` opens `${serverUrl}/login?mobile_redirect_uri=…` (`src/lib/oauth.ts:49-56`, `src/screens/LoginScreen.tsx:128-135`). `serverUrl` comes from probing `/.well-known/jmap` on `domain`, `mail.domain`, `webmail.domain` (`src/lib/server-discovery.ts:70-72, 143-170`) — i.e. it finds the Stalwart host, and then assumes a Bulwark webmail lives at that same origin. When webmail is on another host (or the server only runs Stalwart), the browser opens a 404/Stalwart admin page and the only way in is the password step.
|
||||
- Fix hint: either (a) do PKCE natively with `expo-web-browser`/`expo-auth-session` against the discovered `authorization_endpoint` (redirect `bulwarkmobile://auth/callback`, client id `bulwark-webmail`, scopes `openid email profile`), or (b) probe `${serverUrl}/api/config` before opening the handoff and ask for the webmail address when it is not a Bulwark instance.
|
||||
|
||||
- [x] **Login error classification is thinner than WEB** — `P3` — `partial` — fixed in b52768e
|
||||
- What WEB does: `classifyLoginError` maps invalid credentials, network, 5xx `server_error`, `totp_required`; 429 is a `RateLimitError` with retry-after (`stores/auth-store.ts:71-96`, `lib/jmap/client.ts:849-853`).
|
||||
- What RN does: `describeLoginError` (`src/lib/login-errors.ts`) has no 402 (MFA), 429 or 5xx branches; `RateLimitError('Rate limited by server')` falls to the generic "Sign-in failed" with the raw message.
|
||||
- Fix hint: add branches for `402` → 2FA copy, `429`/`RateLimitError` → "try again in N s", `5xx` → "server error, try later".
|
||||
|
||||
- [x] **Saved-username suggestions on the login form** — `P3` — `missing` — fixed in 2404717
|
||||
- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (`login/page.tsx:500-517`).
|
||||
- What RN does: `EmailStep`/`PasswordStep` start empty; in add mode `ChooseStep` only offers the known server, not known usernames.
|
||||
- Fix hint: prefill/offer `accounts[].username` from the registry (and remember the last typed address in AsyncStorage) in `EmailStep`.
|
||||
|
||||
- [x] **Login flow strings are hard-coded English** — `P3` — `partial` — fixed in 2404717
|
||||
- What WEB does: every login string is localized (`login.*` keys used throughout `login/page.tsx`).
|
||||
- What RN does: `LoginScreen.tsx`, all `src/screens/login/*.tsx`, `QrScanModal.tsx` and `login-errors.ts` use literal English while `SettingsScreen` already uses `useLocaleStore().t` (`src/screens/SettingsScreen.tsx:169-190`).
|
||||
- Fix hint: route through `t()` with `login.*` keys (the i18n audit may cover this; listed here so it is not lost).
|
||||
|
||||
- [x] **QR hint copy points to a settings page that does not exist** — `P3` — `bug` — fixed in b52768e
|
||||
- What WEB does: the pairing QR is generated in Settings → Security ("Link device", `components/settings/account-security-settings.tsx:894-1026`).
|
||||
- What RN does: hints say "Settings → Devices → Add phone" (`src/components/QrScanModal.tsx:66`) and "Settings → Devices" (`src/screens/LoginScreen.tsx:221`).
|
||||
- Fix hint: change the copy to Settings → Security → Link device.
|
||||
|
||||
- [x] **Client TLS certificate support is Android-only and does not cover push/SSE** — `P3` — `partial` (native issue #3) — fixed in edc26ce (live updates fall back to polling with a cert; redirects handled in JS (6f747db); iOS implementation still deferred)
|
||||
- What WEB does: N/A (the browser negotiates mTLS).
|
||||
- What RN does: `src/lib/client-cert.ts` bridges an Android `BulwarkClientCert` module and `secureFetch` is used by the JMAP client, blob and discovery code. Gaps: iOS has no implementation (`getNative` returns null off Android, `client-cert.ts:34-45`); the JMAP SSE stream uses `EventSource` with a plain header (`src/api/push.ts:120-137`) so live updates fail behind mTLS; the system browser used for the webmail handoff cannot present the cert; the abort signal does not reach the native path (`server-discovery.ts:103-135` works around it).
|
||||
- Fix hint: document the limits in the native issue; route SSE through a native fetch-based reader or the `react-native-sse` fetch option when a cert alias is set; consider an iOS `URLSession` delegate implementation.
|
||||
|
||||
### Session
|
||||
|
||||
- [x] **Relative session URLs are not resolved** — `P2` — `bugfix-parity` (changelog 1.9.0 "Resolve relative session URLs without corrupting URI templates") — fixed in 0b1c240 (unified-inbox.ts copy is with the mail-list agent)
|
||||
- What WEB does: `rewriteSessionUrl` prefixes the connected origin when `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` have no scheme, and rewrites the origin of absolute ones without touching `{accountId}`/`{blobId}` templates (`lib/jmap/client.ts:1091-1110`).
|
||||
- What RN does: `rewriteSessionUrls` only rewrites absolute URLs; `extractOrigin` returns null for `/jmap/` so the relative value is passed through unchanged and `secureFetch('/jmap/')` fails (`src/api/jmap-client.ts:320-340`). Same in the unified inbox `rewriteApiUrl` (`src/api/unified-inbox.ts:55-60`).
|
||||
- Fix hint: when the URL has no `^https?://`, return `serverOrigin + (url.startsWith('/') ? url : '/' + url)`; keep the plain string splitting (the comment about the RN URL polyfill corrupting templates still applies).
|
||||
|
||||
- [x] **Redirected session fetch may lose the Authorization header** — `P3` — `partial` — fixed in 0b1c240
|
||||
- What WEB does: `fetchSessionResponse` detects a redirected 200 with no accounts/username (Safari and some auth proxies drop `Authorization` on redirect) and refetches `response.url` with the header (`lib/jmap/client.ts:911-925`).
|
||||
- What RN does: `fetchSession` trusts the redirected response (`src/api/jmap-client.ts:363-383`); Stalwart 0.16.19 answers `/.well-known/jmap` with a 307 to `/jmap/session` (verified against stw-test19), and iOS `NSURLSession` can strip the header on redirect, which ends in `resolveAccountId` throwing "No account found in JMAP session".
|
||||
- Fix hint: mirror the WEB check (`response.redirected` and empty `accounts`/`username` → refetch `response.url` with the header).
|
||||
|
||||
- [x] **Transient token-endpoint failure evicts the account** — `P2` — `bugfix-parity` (changelog 1.7.6 "Keep the session when the auth server is briefly unreachable", 1.7.8 "End refresh loops on sign-out and back off failed retries") — fixed in 2c0dbd1
|
||||
- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (`app/api/auth/token/route.ts:143`); 5xx/network → 503 and the client keeps the account and retries with backoff (`stores/auth-store.ts:1209-1226`, `1792-1799`, `isTransientAuthError` at `:106`).
|
||||
- What RN does: `refreshOAuthAccessToken` throws `HandoffError` for any non-OK status or network error (`src/lib/oauth.ts:239-241`); `forceRefreshToken` turns every failure into `false` (`src/api/jmap-client.ts:211-221`) so an expired access token + a 5xx/offline token endpoint becomes `AuthenticationError`, and `restoreSession`/`switchAccount`/`retrySession` then delete the keychain entry (`src/stores/auth-store.ts:397-405, 505-512, 569-586`).
|
||||
- Fix hint: in `refreshOAuthAccessToken` throw a distinct transient error for network failures, 429 and 5xx; in `fetchSession`/`request` rethrow it as `NetworkError` instead of `AuthenticationError`; only 400/401/403 from the token endpoint are definitive.
|
||||
|
||||
- [x] **A revoked/expired refresh token never leads back to the login screen** — `P2` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (`stores/auth-store.ts:1209-1214`, `markSessionExpired`, `login/page.tsx` `session_expired` banner).
|
||||
- What RN does: `request()` throws `AuthenticationError('Session expired')` (`src/api/jmap-client.ts:433`) but nothing outside `auth-store`/`login-errors` handles that class (grep over `src/`); the email store just records the message. `retrySession` returns early because `get().session` is still the stale object (`src/stores/auth-store.ts:554`). The user is stuck on a dead session until a relaunch.
|
||||
- Fix hint: give `JMAPClient` an `onAuthFailure` callback (set by auth-store) that runs the `AuthenticationError` branch of `retrySession` (clear creds for the active account, `isAuthenticated:false`, error "Session expired"); or clear `session` in the store on that error so `retrySession` can run.
|
||||
|
||||
- [x] **Logout does not revoke the OAuth refresh token** — `P3` — `partial` — fixed in 4bebf70 (pairing bundles are deliberately not revoked)
|
||||
- What WEB does: `DELETE /api/auth/token` posts the refresh token to the IdP `revocation_endpoint` on logout/remove/logout-all (`app/api/auth/token/route.ts:181-254`).
|
||||
- What RN does: `logout`/`logoutAll` only delete the SecureStore entry (`src/stores/auth-store.ts:286-357`, `src/api/jmap-client.ts:350-361`); the refresh token stays valid server-side.
|
||||
- Fix hint: discover `revocation_endpoint` from `${serverUrl}/.well-known/oauth-authorization-server` and POST `token=<refresh>&token_type_hint=refresh_token&client_id=…` best-effort. Caveat: a QR-paired phone shares the desktop's refresh token (`app/api/auth/pair/create/route.ts:18-22`), so revoking it would also sign out the desktop — only revoke bundles that came from the browser handoff, or accept that behaviour deliberately.
|
||||
|
||||
- [x] **No request deadline on JMAP calls** — `P3` — `partial` (changelog 1.8.1 "Time out stalled JMAP requests so a send can't hang forever (#702)") — fixed in 0b1c240
|
||||
- What WEB does: `timedFetch` aborts when no response headers arrive within 30 s (300 s for blob transfers) and does not retry timed-out non-idempotent requests (`lib/jmap/client.ts:786-816`).
|
||||
- What RN does: `request()`/`fetchSession` use bare `secureFetch` with no timeout (`src/api/jmap-client.ts:363-383, 410-445`); only the native-cert path (30 s) and the discovery probe (2.5 s) have deadlines.
|
||||
- Fix hint: add an `AbortController` timeout in `request()` (and blob helpers), surfacing a distinct timeout error; do not auto-retry sends.
|
||||
|
||||
- [x] **SSE stream is opened without a token freshness check** — `P3` — `partial` (verify with the push audit) — fixed in edc26ce
|
||||
- What WEB does: SSE uses `authenticatedFetch`, so a 401 triggers the refresh path.
|
||||
- What RN does: `connectEventSource` reads `jmapClient.authHeader` once (`src/api/push.ts:137`) without `ensureFreshToken` (private, `src/api/jmap-client.ts:195`), so a nearly-expired access token can be used for a long-lived stream.
|
||||
- Fix hint: expose an `ensureFreshToken()`-then-header helper on the client and call it before connecting/reconnecting the stream.
|
||||
|
||||
### Multi-account
|
||||
|
||||
- [x] **A failed "add account" destroys the active session** — `P1` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: connects the new account with a fresh `JMAPClient` and only snapshots/clears the previous account after `connect()` succeeded (`stores/auth-store.ts:709-716`, `957-962`).
|
||||
- What RN does: `login()` and `completeOAuthHandoff` call `jmapClient.reset()` and reset contacts/calendar *before* connecting (`src/stores/auth-store.ts:110-114, 175-180`). On failure the store still says the previous account is active, but the singleton now holds the wrong credentials and no session; cancelling the modal (`App.tsx:470-481`) restores nothing, so every request throws "Not connected" until relaunch.
|
||||
- Fix hint: connect with a throwaway `new JMAPClient()` (or keep the current creds/session in locals) and only adopt the new connection into the singleton after success; on failure `await jmapClient.loadAccount(previousActive)`.
|
||||
|
||||
- [x] **A failed account switch leaves the client half-switched** — `P2` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: on restore failure the previous account's client is re-activated (`stores/auth-store.ts:1546-1565`); rate-limited/transient failures keep both accounts.
|
||||
- What RN does: `loadAccount(target)` overwrites `credentials` and nulls `session`/`_accountId` on failure (`src/api/jmap-client.ts:244-273`); `switchAccount` restores only the email-store view (`src/stores/auth-store.ts:393-411`) and leaves the store's `session` set, so `retrySession` short-circuits (`:554`) and the previous account is dead.
|
||||
- Fix hint: in the catch, `await jmapClient.loadAccount(previousActive)` (or restore the saved in-memory creds/session) before returning; alternatively set `session: null` so the network-recovery watcher re-establishes it.
|
||||
|
||||
- [x] **Account display name is never refreshed from the server (#900)** — `P2` — `missing` (changelog 1.9.0 "Refresh the account display name from the Stalwart principal on login, restore, and switch") — fixed in b52768e
|
||||
- What WEB does: seeds `displayName`/`email` from the primary identity at login, then `syncAccountDisplayName` reads `x:Account/get` (principal "Full name") on login, restore and switch and updates the registry (`stores/auth-store.ts:146-176, 741-751`; `lib/stalwart/principal.ts`).
|
||||
- What RN does: `addAccount({ displayName: username, email: username })` (`src/stores/auth-store.ts:123-131, 186-193`) and nothing ever updates it; the drawer header shows the raw username (`src/components/SidebarDrawer.tsx:297`). `fetchPrincipal` already exists (`src/api/account-security.ts:157-176`) but only feeds the security screen, and saving a new display name there does not touch the registry.
|
||||
- Fix hint: after each successful connect run `Identity/get` (name/email) and, when `urn:stalwart:jmap` is in the account's `accountCapabilities`, `fetchPrincipal()`; `accountStore.updateAccount(id, { displayName, email })`; also update on `updateDisplayName` success.
|
||||
|
||||
- [x] **OAuth account id/email derived from the raw JMAP username** — `P3` — `partial` — fixed in b52768e (email refreshed from the primary identity; registry id unchanged)
|
||||
- What WEB does: prefers the primary identity email over `Session.username` for OAuth/SSO because OIDC `preferred_username` may not be an address (`stores/auth-store.ts:946-951, 1096`).
|
||||
- What RN does: `connectWithOAuth` uses `session.username || accessToken.slice(0, 8)` (`src/api/jmap-client.ts:135-136`), so the same mailbox logged in via password and via OAuth can produce two registry entries, and the drawer synthesizes `username@host`.
|
||||
- Fix hint: read `Identity/get` after connect and use the primary identity email for `email` (and ideally for the registry id).
|
||||
|
||||
- [ ] **Cannot remove a non-active account** — `P2` — `missing` (changelog 1.7.8 "Remove a specific account from the switcher")
|
||||
- What WEB does: per-row remove button in the switcher (`components/layout/account-switcher.tsx:138-143, 322-333`) backed by `removeAccount` which tears down the client, evicts caches and clears cookies (`stores/auth-store.ts:1382-1401`).
|
||||
- What RN does: only "Sign out of <active>" and "Sign out all" (`src/components/SidebarDrawer.tsx:400-437`).
|
||||
- Fix hint: add a per-row remove (long-press or trailing X) that runs `teardownPushNotificationsForAccount(id)`, `jmapClient.clearAccountCredentials(id)`, `useEmailStore.removeAccount(id)`, `accountStore.removeAccount(id)`.
|
||||
|
||||
- [ ] **Switcher shows stale connection state and no error indicator** — `P3` — `partial`
|
||||
- What WEB does: shows `hasError` (alert icon) with `errorMessage` and a live `isConnected` dot per account (`account-switcher.tsx:296-303`).
|
||||
- What RN does: renders only the `isConnected` dot (`SidebarDrawer.tsx:355-361`); `hasError`/`errorMessage` (set in `restoreSession`) are never displayed, and since only the active account is ever connected, other rows show whatever value they had last.
|
||||
- Fix hint: render `hasError`; show the dot only for the active account (or treat inactive as "cached").
|
||||
|
||||
- [x] **Accounts list in Settings (reorder, set default, add) is missing** — fixed in 490355c — `P3` — `missing` (changelog 1.7.2 "List and reorder logged-in accounts from settings (#282)", 1.7.7 "Pin the default account on top and drag-to-reorder") — move up/down + set default + remove + add; account-store.reorderAccounts
|
||||
- What WEB does: `AccountSettings` lists all accounts with move up/down, drag reorder, set-default, add (`components/settings/account-settings.tsx:182-220`); switcher pins the default first (`lib/account-utils.ts:117-142`).
|
||||
- What RN does: `AccountSettings` shows only the active account's fields (`src/components/settings/AccountSettings.tsx`); the drawer lists accounts in registry order with set-default only (`SidebarDrawer.tsx:390-406`).
|
||||
- Fix hint: add an accounts section with set-default/remove/reorder (`accountStore` needs a `reorderAccounts`).
|
||||
|
||||
- [x] **AccountSettings reports the wrong auth method and never shows storage** — fixed in 490355c — `P3` — `rn-only-bug` — jmapClient.usesBearerAuth + Quota/get (src/api/quota.ts)
|
||||
- What WEB does: shows OAuth vs Basic from `authMode` and a quota bar (`account-settings.tsx:151-180`).
|
||||
- What RN does: `authMode = props.authMode ?? 'basic'` and `quotaUsed = props.quotaUsed ?? 0` (`AccountSettings.tsx:46-47`) but `SettingsScreen` renders `<Component />` with no props (`SettingsScreen.tsx:242`), so OAuth accounts read "Basic" and the storage row never appears (RN has no `Quota/get` at all — flag for the mail/settings audit).
|
||||
- Fix hint: derive from `jmapClient.usesBearerAuth` (or persist `authMode` on `AccountEntry`); fetch quota via `Quota/get` when `urn:ietf:params:jmap:quota` is advertised.
|
||||
|
||||
- [ ] **Hard account cap of 5** — `P3` — `partial`
|
||||
- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (`lib/account-utils.ts:75-107`).
|
||||
- What RN does: `MAX_ACCOUNTS = 5` (`src/lib/account-utils.ts:1`) although only the active account holds a live connection.
|
||||
- Fix hint: raise the constant (or drop the cap) once the unified inbox cost per account is acceptable.
|
||||
|
||||
- [ ] **Shared/group account settings scope is missing** — `P3` — `missing` (changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — deferred: shared accounts are now listed read-only in AccountSettings (490355c); scoped filter/vacation stores need an accountId parameter first
|
||||
- What WEB does: lists `client.getSharedAccounts()` (non-primary) on the Accounts page and enters a scoped settings mode (filters, vacation, calendars, contacts) via `managed-account-store` (`components/settings/account-settings.tsx:43-48, 112-118, 225-262`; `stores/managed-account-store.ts`).
|
||||
- What RN does: `getSharedMailAccounts()` exists for mail/unified inbox (`src/api/jmap-client.ts:480-495`) but there is no shared-account listing or scoped settings.
|
||||
- Fix hint: list non-personal `session.accounts` in `AccountSettings` and pass an `accountId` into the filter/vacation stores.
|
||||
|
||||
- [ ] **Unified-inbox shared-account filter disagrees with `getSharedMailAccounts`** — `P3` — `rn-only-bug`
|
||||
- What WEB does: a non-personal account counts as mail-capable even when its `accountCapabilities` omit mail (`lib/jmap/client.ts:4421-4445`); RN's own `getSharedMailAccounts` mirrors that (`jmap-client.ts:486-493`).
|
||||
- What RN does: the unified inbox requires `accountCapabilities == null || MAIL in accountCapabilities` (`src/api/unified-inbox.ts:208-210`), so a shared account that advertises other capabilities but not mail is silently skipped there while it appears in the sidebar.
|
||||
- Fix hint: reuse the `!advertisesMail && info.isPersonal → skip` rule from `getSharedMailAccounts`.
|
||||
|
||||
### Account security settings
|
||||
|
||||
- [x] **Account Security screen is dead against every Stalwart server (native issue #47)** — `P1` — `rn-only-bug` — fixed in 032732c
|
||||
- What WEB does: probes `client.hasAccountCapability('urn:stalwart:jmap')`, i.e. the *account-level* capability map (`stores/account-security-store.ts:276-290`, `lib/jmap/client.ts:4173-4177`, `lib/stalwart/principal.ts:33`).
|
||||
- What RN does: `isStalwartSupported()` checks the *session-level* `session.capabilities` (`src/api/account-security.ts:51-54`). Verified against Stalwart 0.16.19 (stw-test19): `urn:stalwart:jmap` is present only under `accounts[<id>].accountCapabilities`, not in the top-level `capabilities`. So `AccountSecuritySettings` always renders "Account security management requires a Stalwart server" (`src/components/settings/AccountSecuritySettings.tsx:584, 622`). The same effect also shows that copy when the session is merely offline (`:583`).
|
||||
- Fix hint: `const acc = session.accounts?.[jmapClient.accountId]; return !!acc?.accountCapabilities?.[STALWART_CAPABILITY] || STALWART_CAPABILITY in (session.capabilities ?? {})`; show an "offline" notice instead of the Stalwart notice when `currentSession` is null.
|
||||
|
||||
- [x] **Push subscriptions: no per-device list/revoke and no forced recreate (#841)** — fixed in f6f26f7 — `P2` — `missing` (changelog 1.9.0 "Per-device revoke for push subscriptions (#841)", "Recreate the push subscription on re-register (#841)")
|
||||
- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes `forceRecreate` so a stale subscription is recreated rather than its expiry refreshed (`components/settings/notification-settings.tsx:95, 115-131, 169`).
|
||||
- What RN does: Enable / Re-register / Disable for this device only (`src/components/settings/NotificationSettings.tsx:81, 186-198`); no device list, and whether Re-register recreates the JMAP `PushSubscription` was not verified here (push audit).
|
||||
- Fix hint: call the relay device-list endpoint the web uses, render rows with revoke (`PushSubscription/set destroy` + relay unregister), and thread a `forceRecreate` flag through `setupPushNotifications`.
|
||||
|
||||
- [x] **Public keys (S/MIME/PGP) and encryption-at-rest configuration are missing** — fixed in ddae1e6 — `P3` — `missing` (changelog 1.8.1 "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings")
|
||||
- What WEB does: `PublicKeysSection` with `x:PublicKey/query|get|set` and `x:AccountSettings/set encryptionAtRest {@type, publicKey, encryptOnAppend, allowSpamTraining}` (`components/settings/account-security-settings.tsx:573-840`; `stores/account-security-store.ts:397-455, 625-700`).
|
||||
- What RN does: read-only `EncryptionSection` showing the `@type` only (`AccountSecuritySettings.tsx:539-555`; `src/api/account-security.ts:150-155`).
|
||||
- Fix hint: port the four store methods to `src/api/account-security.ts` and add a key list + encryption picker.
|
||||
|
||||
- [x] **OAuth `state` is generated with `Math.random`** — `P3` — `rn-only-bug` — fixed in 2c0dbd1
|
||||
- What WEB does: `crypto.getRandomValues` for verifier/state (`lib/oauth/pkce.ts:11-27`).
|
||||
- What RN does: `randomState()` uses `Math.random` (`src/lib/oauth.ts:41-46`) although a CSPRNG helper with `getRandomValues`/`randomUUID` fallbacks already exists in `src/lib/totp.ts:16-46`. The state is the only guard against a forged `bulwarkmobile://` redirect delivering foreign credentials.
|
||||
- Fix hint: export `randomBytes` from `totp.ts` (or a shared `random.ts`) and use it here.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows NOT to redo)
|
||||
- QR login payloads: WEB emits `bulwarkmail://pair?server=<webmailBase>&code=…` (`account-security-settings.tsx:971`); RN parses it plus a `connect` variant and bare URLs (`src/lib/oauth.ts:135-162`) and redeems at `/api/auth/pair/redeem` (`:165-207`) into the same OAuth bundle the browser handoff yields.
|
||||
- Webmail handoff (`mobile_redirect_uri`/`mobile_state`, fragment transport, state check, password/oauth flows) matches `login/page.tsx:115-130, 282, 638-650` and `auth/callback/page.tsx` mobile branch.
|
||||
- Token refresh: coalesced per refresh token (`src/lib/oauth.ts:210-262`, WEB `refreshPromises`); cached access token reused until 60 s before expiry (#552; `jmap-client.ts:195-209`); client id always carried in the bundle so #873 does not apply; rotated refresh tokens persisted.
|
||||
- Session restore: waits for AsyncStorage hydration, restores active → default account, keeps credentials and shows cached data on `NetworkError` (mirrors 1.7.6 "keep the session when the auth server is briefly unreachable"), evicts on definitive `AuthenticationError` (mirrors 1.6.0 "evict unrecoverable basic-auth accounts"), legacy single-slot migration.
|
||||
- Logout: revokes this account's push subscription first, clears only that account's caches, switches to default/next account or falls back to the login screen; "Sign out all" wipes everything.
|
||||
- Switch: email store is swapped before the network round-trip so the cached list shows immediately (same intent as 1.7.8 "eliminate the full-screen flash when switching accounts").
|
||||
- Shared/group discovery: `getSharedMailAccounts` applies the WEB rule (non-personal OR advertises mail); unified inbox namespaces by `sourceAccountId`/`jmapAccountId`.
|
||||
- Account security API (password change, display name, TOTP enrol/disable with `notUpdated` surfaced — 1.7.5 "Surface server errors on password change and TOTP toggle", app passwords, API keys with expiry/allowed IPs, principal/encryption reads, `Promise.allSettled` for forbidden principal reads) mirrors `stores/account-security-store.ts`; password/TOTP/display-name sections hidden for bearer sessions like WEB `isOAuth`.
|
||||
- Server discovery by email domain (`/.well-known/jmap` probe on bare/`mail.`/`webmail.` hosts, 401 counts as a hit, known servers trusted) is RN's equivalent of WEB's admin server list / auto-pick-by-domain (#799); neither side does `_jmap._tcp` SRV.
|
||||
- Login error → session-expired banner: RN's `ChooseStep` shows the store error ("Session expired") like WEB's `session_expired` banner.
|
||||
- `AuthenticationError` on 401, `RateLimitError` on 429 with Retry-After parsing in `request()`.
|
||||
|
||||
## N/A on mobile
|
||||
- "Remember me" (RN always stores credentials in the device keychain), `rememberMeEnabled`/`SESSION_SECRET` cookie logic, per-slot cookie handling (`cookieSlot`, `oauth_cookie_slot`), orphan-cookie adoption, `serverIdentifiers`/`classifySessionMatch` slot→token desync guard (RN keys credentials per registry id, no shared slot).
|
||||
- Server-configured login branding/notice (`LOGIN_SHOW_TOTP`, `LOGIN_SHOW_VERSION`, logos, imprint/privacy links, company name), OAuth-only mode / auto-SSO / iframe embedded SSO (#69), server picker dropdown (#799), custom JMAP endpoint toggle, demo mode, dev-mode login, CORS error classification.
|
||||
- Impersonation (`/api/auth/impersonate`, `%master` suffix stripping, impersonation reconciler), admin login.
|
||||
- TOTP re-auth dialog (`totp-reauth-store`/`totp-reauth-dialog`): only used by WEB's legacy `password$totp` basic-auth path for pre-0.16 Stalwart; once RN gains TOTP via the token exchange no re-auth is needed.
|
||||
- "Link device" QR generator (RN is the device being linked); TOTP enrolment QR image (same-device — RN's `otpauth://` deep link + manual secret is the right shape).
|
||||
- 30 s keep-alive ping / `connectionLost` state (RN uses NetInfo + `retrySession`), per-tab SSE budget (#702), settings-sync enable/disable on login (settings audit), Stalwart passthrough auth context (`/api/auth/stalwart-context`), plugin logout hooks.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Mail list, mailboxes/folders, unified views, search, tags, batch actions
|
||||
|
||||
## Summary
|
||||
RN covers the core single-folder loop well (folder tree incl. shared/group accounts with owner-routed mutations, incremental Email/queryChanges refresh, offline seed, swipe actions with reveal mode, multi-select with archive/move/delete/tag and an undo snackbar WEB does not even have). The biggest gaps are: (1) tags are invisible in the list and there is no tag view/tag counts; (2) spam/not-spam only moves the message and never flips `$junk`/`$notjunk` (#850), and there is no not-spam in the list at all; (3) search is always folder-scoped (WEB defaults to all folders, #788) with no history/suggestions; (4) the unified inbox is a one-shot, inbox-only, cross-account-only screen with no actions, paging, counts, Unread/Starred/All-mail views or per-role views; (5) folder management is create/rename/delete only (no subfolders, move, reorder, empty folder, mark folder read); (6) several WEB bugfixes are still live in RN: #771 substring dedup hides folders, permanent delete has no confirmation, Sent/Drafts show sender instead of recipient, "Never" mark-as-read marks read instantly, pins are written as `$important` instead of `$pinned`. One P1 RN-only bug: opening a message from the unified inbox that is not in the active folder page renders another message's body in the pager.
|
||||
|
||||
## Findings
|
||||
|
||||
### Folder tree / mailboxes
|
||||
|
||||
- [x] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` — fixed in 516c9c9
|
||||
- What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0).
|
||||
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
|
||||
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
|
||||
|
||||
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus.
|
||||
- What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill).
|
||||
- Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places.
|
||||
|
||||
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`).
|
||||
- What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
|
||||
|
||||
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account.
|
||||
- What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`.
|
||||
- Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
|
||||
|
||||
- [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` — partial in 6ae21d5: "Move under" picker + parent re-fetch done; deferred: sortOrder reorder UI (no drag-and-drop on mobile yet)
|
||||
- What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`.
|
||||
- What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`).
|
||||
- Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`.
|
||||
|
||||
- [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` — partial in 6ae21d5: role picker + #288 role icons done; deferred: per-folder custom icons and the colorful-icon toggle (cosmetic)
|
||||
- What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`).
|
||||
- What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment.
|
||||
- Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type).
|
||||
|
||||
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
|
||||
- What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`).
|
||||
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
|
||||
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
|
||||
|
||||
- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`).
|
||||
- What RN does: nothing — the only way is select-all-on-page + delete, page by page.
|
||||
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
|
||||
|
||||
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`).
|
||||
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
|
||||
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
|
||||
|
||||
- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
|
||||
- What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart.
|
||||
- Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
|
||||
|
||||
- [x] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` — fixed in f07e039
|
||||
- What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780).
|
||||
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
|
||||
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
|
||||
|
||||
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`).
|
||||
- What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
|
||||
|
||||
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account").
|
||||
- What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`).
|
||||
- Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`.
|
||||
|
||||
### Unified mailbox / cross-account views
|
||||
|
||||
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`).
|
||||
- What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A).
|
||||
- Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`).
|
||||
|
||||
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists.
|
||||
- What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row.
|
||||
- Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge.
|
||||
|
||||
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`).
|
||||
- What RN does: none.
|
||||
- Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later.
|
||||
|
||||
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`).
|
||||
- What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`).
|
||||
- Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
|
||||
|
||||
- [x] **Unified search** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8).
|
||||
- What RN does: no search box on `UnifiedInboxScreen`.
|
||||
- Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
|
||||
|
||||
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views".
|
||||
- What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open.
|
||||
- Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides.
|
||||
|
||||
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`).
|
||||
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
|
||||
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
|
||||
|
||||
- [x] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` — fixed in c8be383
|
||||
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`).
|
||||
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
|
||||
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
|
||||
|
||||
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in 6ae21d5
|
||||
- What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot.
|
||||
- Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account.
|
||||
|
||||
### List rendering and behaviour
|
||||
|
||||
- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in 3194b5e
|
||||
- What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`).
|
||||
- What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`).
|
||||
- Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added).
|
||||
|
||||
- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`).
|
||||
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
|
||||
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
|
||||
|
||||
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
|
||||
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
|
||||
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
|
||||
|
||||
- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`).
|
||||
- What RN does: none (no reference to `$answered` anywhere in `src/`).
|
||||
- Fix hint: add the two icons next to the star/paperclip in `EmailRow`.
|
||||
|
||||
- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in 3194b5e
|
||||
- What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`).
|
||||
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
|
||||
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
|
||||
|
||||
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4
|
||||
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`).
|
||||
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
|
||||
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
|
||||
|
||||
- [x] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads".
|
||||
- What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere).
|
||||
- Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account.
|
||||
|
||||
- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner.
|
||||
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
|
||||
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
|
||||
|
||||
- [ ] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` — list side fixed in 3194b5e (single, batch, swipe via `src/lib/delete-confirm.ts`); deferred: thread screen `onDelete` belongs to the viewer area — use `confirmPermanentDelete()`/`isPermanentDelete()` from `src/lib/delete-confirm.ts`
|
||||
- What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`).
|
||||
- What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`).
|
||||
- Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`.
|
||||
|
||||
- [ ] **"Never" mark-as-read (`markAsReadDelay === -1`) marks read instantly** — `P2` — `rn-only-bug`
|
||||
- What WEB does: `-1` → never, `0` → instant, else timer (`components/mail/mail-app.tsx:1511-1528`).
|
||||
- What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant".
|
||||
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
|
||||
|
||||
- [x] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` — fixed in f07e039
|
||||
- What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`).
|
||||
- What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows).
|
||||
- Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`.
|
||||
|
||||
- [x] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` — fixed in f07e039
|
||||
- What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`).
|
||||
- Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window".
|
||||
|
||||
- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7).
|
||||
- What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing.
|
||||
- Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam".
|
||||
|
||||
- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in 3194b5e
|
||||
- What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`).
|
||||
- What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk".
|
||||
- Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op.
|
||||
|
||||
- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8).
|
||||
- What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`).
|
||||
- Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`.
|
||||
|
||||
- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in 3194b5e
|
||||
- What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`).
|
||||
- What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message.
|
||||
- Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`).
|
||||
|
||||
- [x] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` — fixed in f07e039
|
||||
- What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`).
|
||||
- What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`).
|
||||
- Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
|
||||
|
||||
- [x] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` — fixed in f07e039
|
||||
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0).
|
||||
- What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it.
|
||||
- Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index.
|
||||
|
||||
- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`).
|
||||
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
|
||||
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
|
||||
|
||||
- [x] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` — fixed in 0205aa0
|
||||
- What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized.
|
||||
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`).
|
||||
- Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
|
||||
|
||||
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4
|
||||
- What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`).
|
||||
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup.
|
||||
- Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
|
||||
|
||||
- [x] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`).
|
||||
- What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message.
|
||||
- Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough.
|
||||
|
||||
- [x] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` — fixed in 0205aa0
|
||||
- What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`).
|
||||
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
|
||||
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
|
||||
|
||||
- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
|
||||
- What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles.
|
||||
- Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`).
|
||||
|
||||
### Search
|
||||
|
||||
- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in 3194b5e
|
||||
- What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries.
|
||||
- What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field.
|
||||
- Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`.
|
||||
|
||||
- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`).
|
||||
- What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`).
|
||||
- Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`.
|
||||
|
||||
- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`).
|
||||
- What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`).
|
||||
- Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`.
|
||||
|
||||
### Tags / keywords
|
||||
|
||||
- [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` — partial in 3194b5e: unknown `$label:` ids listed/removable in TagSheet; deferred: visibility, nesting, reorder, rename migration (device-local tag model, see native issue #1)
|
||||
- What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`).
|
||||
- What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1).
|
||||
- Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority.
|
||||
|
||||
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
|
||||
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes).
|
||||
- What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`).
|
||||
- Fix hint: remove the button or guard with `Alert.alert` confirm.
|
||||
|
||||
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`).
|
||||
- What RN does: none; after a reinstall all tags must be re-typed with the exact id.
|
||||
- Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
|
||||
|
||||
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`.
|
||||
- What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other.
|
||||
- Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
|
||||
|
||||
### Offline cache (RN only, sanity check)
|
||||
|
||||
- [x] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct).
|
||||
- Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists).
|
||||
|
||||
## Verified at parity (do not redo)
|
||||
- Folder tree: nesting, per-account grouping of shared/group accounts with unread roll-up, expanded state persisted, role priority sort (`src/lib/mailbox-tree.ts`, `SidebarDrawer.tsx`); create/rename/delete own folders with server error surfaced (`FolderSettings.tsx`); delete-with-emails confirm.
|
||||
- Shared/group folders: queries and every mutation (read/star/pin/tag/move/archive/delete/undo/import) routed to the owner account with the unprefixed id (`refFor`, `src/stores/email-store.ts:64-99`); thread screen fetches by owner (`EmailThreadScreen.tsx:90-96`); state-change handling per account (`email-store.ts:981-1015`).
|
||||
- Folder tree preserved when a refresh fails (#780 equivalent, `email-store.ts:604-612`), shared-account failure isolated (`:595-601`); Mailbox/changes incremental sync with drain.
|
||||
- Missing Archive/Trash/Junk → error alert (`EmailListScreen.tsx:363-387`), never silently destroys when Trash is missing (#195 equivalent).
|
||||
- Delete semantics: trash / trash-and-read (#323) / permanent / junk auto-permanent, single and batch (`email-store.ts:1268-1335`, `1434-1527`); undo snackbar for archive/delete/move (single + batch) — RN is ahead of WEB here.
|
||||
- Archive single / by year / by month with folder creation in one request, batch archive, "Reorganize existing archive" (`src/api/email.ts:579-692`, `ReadingSettings.tsx:105-159`).
|
||||
- Swipe actions: configurable per direction, instant and reveal modes, pure gesture module with tests, stale-props fix (`SwipeableRow.tsx`, `swipe-gesture.ts`); more actions than WEB (pin, move). RTL: RN ships no RTL locales, nothing to do yet.
|
||||
- Multi-select: long-press, select-all-visible/indeterminate, batch star/read/tag/move/archive/delete (`EmailListScreen.tsx:320-500`).
|
||||
- Pagination via `onEndReached`, pull-to-refresh, incremental `Email/queryChanges` + `Email/changes` refresh with per-account state tokens, base-view restore after search (#10), sort change invalidates snapshots (#5), offline seed and fallback.
|
||||
- Search: full-text with wildcard suffix (`src/lib/search-utils.ts` = WEB `toWildcardQuery`), from/to/subject/date/attachment/unread/starred tri-state filters, chips row, search inside a shared folder routed to its owner.
|
||||
- Keyword writes send the whole `keywords` map (no JSON-pointer escaping issue, no `false` values) — not affected by 5c484af1 / be97c8bf; tag ids normalized exactly like WEB `normalizeKeywordLevel` (`KeywordSettings.tsx:118-123`); batch tag toggle via `TagSheet` with "all have it" semantics.
|
||||
- Unread dot (#27), date formats smart/relative/full with 12/24h, preview toggle, density-aware rows, sender favicon avatars with failure cache, `.eml`/`.zip` import into the open folder, Scheduled quick view, "Include group inboxes" setting and shared badge/account dot in the unified list.
|
||||
- Mark-as-read delay (0/3s/5s) in the thread screen; both clients still display `receivedAt` in the list (WEB `lib/email-date.ts` is not wired into the list yet).
|
||||
|
||||
## N/A on mobile
|
||||
- Category tabs / message-list tabs (`stores/message-list-tabs-store.ts`) — plugin-registered; RN has no plugin runtime.
|
||||
- Hover quick actions, keyboard navigation/shortcuts, right-click context menus, drag-and-drop of mail onto folders/tags and folder-tab drag (Pro) — desktop interactions; RN has swipe + selection instead.
|
||||
- Admin policy gates for unified/cross views (`usePolicyStore` feature flags) — RN has no admin policy channel; only the user-side toggles apply.
|
||||
- Return-to-list-after-action setting — the RN thread screen always returns after delete/archive/spam.
|
||||
- "Refresh" toolbar button and F5/Ctrl+R interception — pull-to-refresh covers it.
|
||||
- Favicon unread badge, sidebar collapse/resize, Pro multi-pane shell, folder subscription (neither client exposes `isSubscribed`).
|
||||
- Plugin search hooks / external search results, `onEmailsFetched` transforms.
|
||||
- Source-folder chip on rows is N/A *until* All-mail/cross views exist in RN.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Email viewer / thread view / message rendering / attachments
|
||||
|
||||
## Summary
|
||||
RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pinch zoom, external-content gate with trusted senders, cid inlining, attachment open/share/save with templated filenames, view source, .eml export, calendar-invitation banner, shared-folder routing for body/attachments/source) but it is a *single-message* viewer, not a thread view, and it never fetches `headers`/`messageId`/`inReplyTo`/`references`, so everything the WEB viewer derives from headers (SPF/DKIM/DMARC, List-Unsubscribe, read receipts, Reply-To, identity badge, correct `In-Reply-To`) is absent or wrong. The biggest gaps: (1) older messages of a thread are unreachable and the pager shows the wrong message when opened from the unified inbox / contact activity; (2) replies write the JMAP id into `In-Reply-To`, ignore `Reply-To`, do not strip the user's own address on reply-all, and forwards drop every attachment; (3) HTML-only messages (same part in `textBody` and `htmlBody`) are rendered and quoted as raw HTML source, and `<style>` blocks are stripped wholesale, which is the most likely root of native issues #46 ("email preview shows css") and #49 ("Unreadable text in dark-mode emails"); (4) external-content blocking only recognises `<img src=http…>`, `background=` and inline `url()`, so srcset/`<source>`/poster/whitespace-obfuscated pixels still load.
|
||||
|
||||
## Findings
|
||||
|
||||
### Thread view / navigation
|
||||
|
||||
- [x] **Thread messages other than the newest are unreachable — fixed in d2ed27f** — `P1` — `missing`
|
||||
- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (`components/email/thread-conversation-view.tsx:98-115`, `171-200`, store `fetchThreadEmails` via `Thread/get` + `Email/get` in `stores/email-store.ts:3574-3640`, `lib/jmap/client.ts:2763-2800`).
|
||||
- What RN does: the list collapses same-thread rows to the newest message with a count badge (`src/screens/EmailListScreen.tsx:224-238`, default `disableThreading: false` at `src/stores/settings-store.ts:266`), the row opens `EmailThreadScreen` with a single `emailId` (`App.tsx:145-151`), and the screen only ever shows that one message (`src/screens/EmailThreadScreen.tsx:113-120`, `685-840`). `getThread` exists in `src/api/email.ts:448` but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping".
|
||||
- Fix hint: on open, call `getThread(threadId, ownerAccountId)` + `getFullEmails(ids)` and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusing `EmailBodyView` per card; keep the horizontal pager for thread-to-thread navigation.
|
||||
|
||||
- [x] **Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in c8be383** — `P1` — `rn-only-bug`
|
||||
- What WEB does: the viewer renders `selectedEmail` directly; prev/next are derived from the current list (`components/email/email-viewer.tsx:5220-5279`).
|
||||
- What RN does: the pager is a `FlatList` over `useEmailStore(s => s.emails)` and the initial index is `Math.max(0, emails.findIndex(id))` (`src/screens/EmailThreadScreen.tsx:128-133`, `535-572`). `UnifiedInboxScreen` keeps its own local list and never writes the store (`src/screens/UnifiedInboxScreen.tsx:35-50`, `61-84`), `ContactActivity` navigates with ids from a contact search (`src/components/contacts/ContactActivity.tsx:170-177`). In both cases the opened id is absent from `emails`, so the visible pane is `emails[0]` (a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act on `activeEmailId`.
|
||||
- Fix hint: when `route.params.emailId` is not in `emails`, render a single-item pager (`data=[{id: emailId}]`) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
|
||||
|
||||
- [x] **Mark-as-read "Never" (-1) marks the message read instantly — fixed in d2ed27f** — `P2` — `rn-only-bug`
|
||||
- What WEB does: `-1` means never auto-mark, `0` instant, `>0` delayed (`components/email/email-viewer.tsx:1218-1238`).
|
||||
- What RN does: `ReadingSettings` offers `Never = -1` (`src/components/settings/ReadingSettings.tsx:167-173`) but the viewer does `if (markAsReadDelay > 0) {timer} else { markRead() }` (`src/screens/EmailThreadScreen.tsx:237-245`), so `-1` behaves like instant.
|
||||
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
|
||||
|
||||
- [x] **Skeleton / loading parity — verified, no action** — `P3` — `verified` (see "Verified at parity"); no action.
|
||||
|
||||
### Body rendering & sanitisation
|
||||
|
||||
- [x] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in 06742ef** — `P1` — `bug`
|
||||
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in `textBody` and `htmlBody`; WEB checks `hasDistinctTextBody` (different partIds) before preferring the text alternative, and routes by the part's `type` (`components/email/email-viewer.tsx:1727-1747`, `components/email/thread-conversation-view.tsx:332-346`; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML").
|
||||
- What RN does: `extractTextBody` returns the html part's value for HTML-only mail, then `rawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null` (`src/components/EmailBodyView.tsx:43-57`, `504-511`). For any HTML-only message whose markup fails `MEANINGFUL_HTML_RE` (`src/lib/email-html.ts:353-362`, e.g. a `<div>`/`<p>` body without `style=`), `rawHtml` is null and the raw source — tags, `<head>`, CSS — is shown as plain text. The same value feeds the reply quote: `plainTextBody()` (`src/screens/EmailThreadScreen.tsx:59-65`, `377`) so replies to HTML-only mail quote escaped HTML source.
|
||||
- Fix hint: compare `textBody[0].partId !== htmlBody[0].partId` (and `part.type === 'text/html'`) before treating `text` as an alternative; in `navigateCompose` pass `htmlBody` (and let `buildInitialHtml` use it) or convert with `htmlToPlainText` (`src/lib/compose-html.ts:108`) when the only body is HTML.
|
||||
|
||||
- [x] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: iframe sanitiser keeps `<style>` (`lib/email-sanitization.ts:60-78`), and when external content is blocked it neutralises `url()`/`@import`/`@font-face` inside the sheet (`stripExternalStyleSheetCss`, `lib/email-sanitization.ts:474-509`, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (`components/email/email-viewer.tsx:2265-2267`).
|
||||
- What RN does: `FORBID_TAGS` includes `'style'` (and `svg`) so every stylesheet is removed before render (`src/lib/email-html.ts:16-28`, `50-68`). Class-based colours vanish: a newsletter with white text via `.hero { color:#fff }` on a `bgcolor` cell renders white-on-white (light) or, after the invert filter, black-on-black. Also `hasNativeDarkMode` is evaluated on the *stripped* HTML inside `wrapEmailHtml` (`src/lib/email-html.ts:226-229`) but on the raw HTML in the component (`src/components/EmailBodyView.tsx:612-613`), so for emails with `@media (prefers-color-scheme: dark)` the CSS inversion is applied but the DOM re-invert pass is skipped.
|
||||
- Fix hint: keep `<style>` (the WebView is an isolated document; CSP already has `style-src 'unsafe-inline'`), port `stripExternalStyleSheetCss` + `decodeCssEscapes` for the blocked case, and evaluate `hasNativeDarkMode` once on the same input.
|
||||
|
||||
- [x] **Dark-mode inversion CSS is an older revision of WEB's — fixed in d2ed27f** — `P2` — `bugfix-parity`
|
||||
- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7 `height:100%` wrappers): background-*image* containers are re-inverted unconditionally and their media get `filter:none`; only bgcolor/`background:` containers use the `:not(:has(media))` guard; nested guard is limited to colour containers; `[style*="height:100%"]` is neutralised; Word/Outlook HTML gets `line-height:1.15` + `MsoNormal` margins and a fallback gutter; `messageSpacing` auto/edge gutter (`components/email/email-viewer.tsx:2200-2253`, `2287-2305`, `2438-2461`).
|
||||
- What RN does: `DARK_INVERSION` applies the `:not(:has(...))` guard to background-image containers too and includes them in the nested `filter:none` rule (`src/lib/email-html.ts:167-185`), the DOM pass only re-inverts containers without media children (`src/components/EmailBodyView.tsx:72-88`); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon, `email-viewer.tsx:3199-3212`, changelog 1.6.0), no `messageSpacing`.
|
||||
- Fix hint: copy the current `darkModeCSS` block and the DOM re-invert loop verbatim into `email-html.ts` / `DARK_REINVERT_SCRIPT`; add a "View in light/dark mode" entry to the More sheet that overrides `renderAsDark` for the current message.
|
||||
|
||||
- [x] **External-content detection misses most tracking vectors (privacy leak) — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: `isExternalResourceUrl` strips C0/space so `"\nhttps://x"` and `h\ttps://` count; blocks `<img srcset>`, `<picture><source>`, `<video poster>`, media `src`, `background=`, inline `url()` incl. CSS escapes, `<style>` url()/@import, and forbids `<link>` in blocking mode; the banner is driven by "something was actually blocked" (`lib/email-sanitization.ts:399-406`, `511-615`, `108-150`; changelog 1.7.8).
|
||||
- What RN does: `hasRemoteContent` only matches `<img src="http…">`, `background="http…"` and `style="...url(http…)"` (`src/lib/email-html.ts:364-369`); when it returns false `shouldBlock` is false and the CSP becomes `img-src data: cid: https: http:` (`src/lib/email-html.ts:235`), so a pixel expressed as `srcset=`, `<source>`, `poster=`, `src=" https://…"` (leading whitespace/newline), or a CSS-escaped `\68ttps://` loads unconditionally under policy `ask`/`block`. `blockRemoteImageSrcs` (`316-347`) is likewise src/srcset/background/style only, and has no `data-blocked-*` restore.
|
||||
- Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP `img-src data: cid:` + `media-src`/`font-src` none whenever policy is `block`/`ask`-not-allowed and sender untrusted, like WEB's `externalBlocked`), port `isExternalResourceUrl` normalisation, add `source/video/audio/poster/link` handling, and show the banner when the strict CSP is in effect and the HTML contains any `http(s)://` resource reference.
|
||||
|
||||
- [x] **Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: swaps `src` for a transparent 1x1 GIF, empties `alt`, `display:none`, and collapses empty `<td>/<div>` wrappers (`lib/email-sanitization.ts:319-320`, `537-546`, `678-704`); hides images that fail to load (`components/email/email-viewer.tsx:2393-2411`, changelog 1.7.7).
|
||||
- What RN does: sets `src=""` and `alt="[remote image blocked]"` (`src/lib/email-html.ts:319-323`), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding.
|
||||
- Fix hint: use the same transparent-GIF + `display:none` swap and port `collapseBlockedImageContainers` as a regex/DOM pass in the injected script; add an `img` `error` listener that hides the element.
|
||||
|
||||
- [x] **mailto: links inside the body open the OS mail handler instead of the app composer — fixed in 06742ef** — `P2` — `missing`
|
||||
- What WEB does: `mailto:` opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leaves `mailto:` to the app's protocol handling, `components/email/email-viewer.tsx:2420-2436`).
|
||||
- What RN does: `onShouldStartLoadWithRequest` hands `mailto:` to `Linking.openURL` (`src/components/EmailBodyView.tsx:715-724`), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark.
|
||||
- Fix hint: parse `mailto:` (address, `subject`, `body`, `cc`) and `navigation.navigate('Compose', { prefillTo, ... })`; the `prefillTo` param already exists (`src/screens/ComposeScreen.tsx:292-298`).
|
||||
|
||||
- [x] **`data:` URL navigations replace the email body — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:717-719`), and `safeUri` keeps `href="data:image/…"` (`src/lib/email-html.ts:109-113`); tapping such a link navigates the WebView to the image with no way back. Only the initial `about:blank` load should be allowed.
|
||||
- Fix hint: allow `data:` only when `request.navigationType === 'other' && !request.isTopFrame`-style initial loads, otherwise return false (optionally open images externally).
|
||||
|
||||
- [x] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: allows only raster `data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)` on media tags, re-checks `srcset` candidates individually, forbids SVG data URIs and `<svg>` (`lib/email-sanitization.ts:24`, `330-388`), and the iframe has no scripting at all.
|
||||
- What RN does: `safeUri` allows any `data:image/*` including `image/svg+xml` (`src/lib/email-html.ts:109-113`), `srcset` values are never scheme-checked except when blocking (`326-329`), and the page CSP must allow `'unsafe-inline'` scripts for the injected bridge (`236-245`), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings).
|
||||
- Fix hint: restrict `data:` to the raster list, scheme-check every `srcset` candidate, and consider parsing with a real HTML parser (e.g. `htmlparser2`/`parse5` on the JS side) instead of regexes; document the residual risk.
|
||||
|
||||
- [x] **Quoted reply text is not collapsed (#480) — fixed in 06742ef** — `P3` — `missing`
|
||||
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed `<blockquote>`, `>`-lines in plain text) behind a "•••" toggle (`lib/quote-collapse.ts:143-197`, `219-259`; changelog 1.7.8).
|
||||
- What RN does: nothing; long threads show every quoted original inline.
|
||||
- Fix hint: port `setupQuoteCollapse` into the injected WebView script (runs on the parsed DOM, uses inline styles only) and `collapsePlainTextQuotes` into `wrapPlainTextEmail`.
|
||||
|
||||
- [x] **Plain-text bodies are always monospace (#830) — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: renders text/plain in the app font by default with a `plainTextFont: 'sans' | 'mono'` setting (`stores/settings-store.ts:329`, `565`; `components/email/email-viewer.tsx:5050-5060`; changelog 1.9.0).
|
||||
- What RN does: `wrapPlainTextEmail` hard-codes `ui-monospace, Menlo, …` (`src/lib/email-html.ts:290`); no setting (`src/stores/settings-store.ts` has no `plainTextFont`).
|
||||
- Fix hint: add the setting (Reading settings) and switch the font-family in `wrapPlainTextEmail`.
|
||||
|
||||
- [x] **Plain-text linkifier only catches http(s) — verified, no action** — `P3` — `verified` (same as WEB `plainTextToSafeHtml`); no action.
|
||||
|
||||
- [x] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: fetches every cid part as an authenticated `blob:` URL regardless of size/type; the browser sniffs `application/octet-stream` image bytes (`components/email/email-viewer.tsx:1509-1575`; changelog 1.8.1 #543).
|
||||
- What RN does: skips parts over `MAX_INLINE_IMAGE_BYTES` (`src/components/EmailBodyView.tsx:15`, `556-559`) and builds `data:${att.type || 'application/octet-stream'};base64,…` (`573`), which Chromium/WebKit may refuse to decode as an image for `application/octet-stream`.
|
||||
- Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g. `File.base64()` from expo-file-system after `downloadInto`) instead of dropping them.
|
||||
|
||||
### Header / meta
|
||||
|
||||
- [x] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched — fixed in ccbe67c** — `P2` — `missing` (root cause for the next five items)
|
||||
- What WEB does: `Email/get` requests `messageId, inReplyTo, references, headers, bodyStructure` and parses `Authentication-Results`, `X-Spam-*`, `X-Spam-LLM` into `email.authenticationResults/spamScore/spamLLM` (`lib/jmap/client.ts:1775-1870`).
|
||||
- What RN does: `EMAIL_FULL_PROPERTIES` stops at `bodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt` (`src/api/email.ts:11-15`); the `Email` type has no `headers`/`messageId` (`src/api/types.ts:65-87`).
|
||||
- Fix hint: add the four properties to `EMAIL_FULL_PROPERTIES`, extend the type, and port `parseAuthenticationResults`/`parseSpamScore`/`extractListHeaders` (`lib/email-headers.ts`) to `src/lib/email-headers.ts`.
|
||||
|
||||
- [x] **No "Show details" panel: CC/BCC/Reply-To/sent vs received/size/MIME/Message-ID/thread id — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (`components/email/email-viewer.tsx:3892-3944`, `4187-4542`).
|
||||
- What RN does: one line `to <names>` (`src/screens/EmailThreadScreen.tsx:806-809`); CC/BCC/Reply-To are fetched but never shown; date/size only (`811-817`).
|
||||
- Fix hint: add CC/BCC rows and a collapsible details block under the sender row; reuse `formatSize`.
|
||||
|
||||
- [x] **SPF/DKIM/DMARC/spam-score chips and spoof-aware identity badge — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (`components/email/email-viewer.tsx:4353-4438`, `lib/email-headers.ts:13-115`); "via <identity>" / sub-address tag badge hidden when spoofed (`components/email/email-identity-badge.tsx:33-68`, changelog 1.7.3).
|
||||
- What RN does: none.
|
||||
- Fix hint: after the headers finding, render chips in the details block; port `isAuthenticationSpoofed` for the badge.
|
||||
|
||||
- [x] **List-Unsubscribe (RFC 2369) banner — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: parses `List-Unsubscribe` (http preferred, else mailto), confirm dialog, sends the mailto unsubscribe through the account itself, remembers dismissals per Message-ID (`components/email/unsubscribe-banner.tsx:64-106`, `components/email/email-viewer.tsx:753-773`, `2672-2680`; changelog 1.7.7).
|
||||
- What RN does: none.
|
||||
- Fix hint: port `parseUnsubscribeUrls`/`isValidUnsubscribeUrl` from `lib/validation.ts`; for mailto call `sendEmail` with the parsed fields; open http via `expo-web-browser`.
|
||||
|
||||
- [x] **Read receipts (MDN, RFC 8098): request banner, send, "always/ask/never", `$mdnsent` — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: detects `Disposition-Notification-To`, offers Send/Ignore (or auto-sends in "always" mode) only in received folders, builds the multipart/report with `lib/mdn.ts`, uploads → `Email/import` into Sent → `EmailSubmission/set` with explicit envelope, then sets `$mdnsent` (`components/email/email-viewer.tsx:2686-2784`, `lib/jmap/client.ts:4080-4130`, `components/email/read-receipt-banner.tsx`).
|
||||
- What RN does: none; no `readReceiptResponse` setting.
|
||||
- Fix hint: port `lib/mdn.ts` verbatim (pure string builder), reuse `uploadBytes` (`src/api/blob.ts:67`) + `importEmailBlob` (`src/api/email.ts:423`) + an `EmailSubmission/set` helper; add the setting.
|
||||
|
||||
- [x] **Reply-To shown / used — fixed in 7f956d6** — folded into the reply-recipients finding below.
|
||||
|
||||
- [x] **Sender / recipient tap actions (popover, view/add contact, copy address) — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: every address is a `RecipientPopover` (contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (`components/email/recipient-popover.tsx`, `components/email/email-viewer.tsx:1140-1170`, `3841-3866`).
|
||||
- What RN does: names are static `Text` (`src/screens/EmailThreadScreen.tsx:794-810`).
|
||||
- Fix hint: wrap sender/recipients in `Pressable` opening a small action sheet (copy, compose to, open `ContactDetail`/`ContactForm` prefilled).
|
||||
|
||||
- [x] **Displayed date uses `receivedAt` and device locale, ignoring the app's time-format setting — fixed in d2ed27f** — `P3` — `bugfix-parity`
|
||||
- What WEB does: `emailDisplayDate` prefers `sentAt` (falls back when missing or >24 h in the future, #891) and formats with the user's `timeFormat`/locale (`lib/email-date.ts:33-45`, `components/email/email-viewer.tsx:3811`).
|
||||
- What RN does: `formatHeaderDate(email.receivedAt)` with `toLocaleDateString(undefined, …)` (`src/screens/EmailThreadScreen.tsx:39-50`, `812-816`), so imported/migrated mail shows the import date and 12/24 h ignores the setting.
|
||||
- Fix hint: port `emailDisplayDate` (already in `src/lib/date-format.ts`?—if not, 15 lines) and use `useSettingsStore(s => s.timeFormat)` + locale from `useLocaleStore`.
|
||||
|
||||
- [x] **Tags, `$important` badge and answered/forwarded state in the header — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: tag badges (removable) under the subject, "Important" pill (`components/email/email-viewer.tsx:3789-3806`); `$answered`/`$forwarded` set after send (`components/mail/mail-app.tsx:1663-1667`).
|
||||
- What RN does: Tag sheet exists but no badges are rendered in the pane; `$answered`/`$forwarded` are never set after a reply/forward (no hit for `$answered` in `src/`).
|
||||
- Fix hint: render `keywordDefs` matches from `email.keywords` under the subject; after a successful send in `ComposeScreen` call `setEmailKeywords(originalId, {...keywords, $answered|$forwarded: true}, accountId)`.
|
||||
|
||||
### Calendar invitation banner
|
||||
|
||||
- [x] **Invitation parsing is not routed to the owning account (#847, #867) — fixed in 8206893** — `P2` — `bugfix-parity`
|
||||
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (`components/email/calendar-invitation-banner.tsx:373-388`, `412-432`; changelog 1.9.0).
|
||||
- What RN does: `parseCalendarBlob(attachment.blobId)` uses `jmapClient.accountId` (`src/components/email/CalendarInvitationBanner.tsx:54`, `src/api/calendar.ts:355-360`) even though `EmailPane` knows `jmapAccountId`; Stalwart answers `CalendarEvent/parse` with an error for a blob in another account, so invitations in group/shared mailboxes show nothing (state `error` → banner hidden).
|
||||
- Fix hint: pass `jmapAccountId` from `EmailPane` into the banner and through to `parseCalendarBlob(blobId, accountId)`.
|
||||
|
||||
- [x] **iMIP method detection ignores the raw ICS `METHOD` and Content-Type params — fixed in 8206893** — `P2` — `partial`
|
||||
- What WEB does: reads `method=` from the part/attachment/header Content-Type, then fetches the raw ICS and reads `METHOD:` (`lib/calendar-invitation.ts:319-339`, `components/email/calendar-invitation-banner.tsx:419-447`), falling back to participant heuristics; `CANCEL`, `REPLY`, `COUNTER`, `REFRESH`, `DECLINECOUNTER`, `PUBLISH`, `ADD` each get their own title/info/actions.
|
||||
- What RN does: `extractMethodFromRawIcs` exists (`src/lib/calendar-invitation.ts:103-106`) but is never called; only `inferInvitationMethod` heuristics are used (`CalendarInvitationBanner.tsx:62`), so a cancellation whose event status is not `cancelled`, or a REPLY from a single attendee, is shown as a plain invitation with RSVP buttons.
|
||||
- Fix hint: fetch the blob text (`getDownloadUrl` + `secureFetch`, as `fetchRawEmail` does) in parallel with parse and prefer `extractMethodFromRawIcs`.
|
||||
|
||||
- [ ] **Banner lacks WEB's trust assessment, actor summary, existing-event / "already in calendar" state, calendar picker, "View in calendar", collapse, sequence badge, counter-proposal review** — `P3` — `partial` — deferred: trust assessment, iTIP method and calendar picker landed in 8206893 (calendar agent); existing-event state, "View in calendar", collapse, sequence badge and counter-proposal review remain open in the calendar area
|
||||
- What WEB does: `getInvitationTrustAssessment` (sender vs organizer + auth results) warning, actor line ("X accepted"), `queryCalendarEvents({uid})` to show "already in calendar" and current RSVP, picker when >1 calendar, "View in calendar" navigation, collapsible card, `sequence` "updated" pill, apply counter proposal for organizers (`components/email/calendar-invitation-banner.tsx:392-474`, `502-553`, `657-672`, `721-752`, `802-1140`).
|
||||
- What RN does: title/date/location/video/organizer rows, Yes/Maybe/No, Add to first writable calendar (`src/components/email/CalendarInvitationBanner.tsx:83-207`); no dedupe check before offering "Add", no picker, no trust warning, no navigation to the event.
|
||||
- Fix hint: port `getInvitationTrustAssessment` (needs the headers finding for auth), look up `useCalendarStore.events` by `uid` to switch to "already in calendar"/current response, add a calendar picker sheet when `calendars.length > 1`, and a "View in calendar" button that navigates to `CalendarScreen` with the start date.
|
||||
|
||||
- [x] **The `.ics` part stays in the attachment list while the banner is shown — fixed in d2ed27f** — `P3` — `bugfix-parity`
|
||||
- What WEB does: hides calendar MIME parts when the banner renders (`components/email/email-viewer.tsx:1591-1600`; changelog 1.4.14).
|
||||
- What RN does: `renderAttachments` filters only inline images (`src/screens/EmailThreadScreen.tsx:713-722`).
|
||||
- Fix hint: filter `isCalendarType(att.type) || name.endsWith('.ics')` when `calendarInvitationParsingEnabled && findCalendarAttachment(email)`.
|
||||
|
||||
### Attachments
|
||||
|
||||
- [x] **Forwarding drops every attachment — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (`components/email/email-composer.tsx:598-616`, #543).
|
||||
- What RN does: `navigateCompose('forward')` passes only from/to/cc/subject/plain body (`src/screens/EmailThreadScreen.tsx:366-382`); the `Compose.replyTo` param has no `attachments` field (`src/navigation/types.ts:7-19`) and `ComposeScreen` never seeds `attachments` from the original (`src/screens/ComposeScreen.tsx:354`). A forwarded invoice arrives without the PDF.
|
||||
- Fix hint: add `attachments?: Attachment[]` to the route param, and in `ComposeScreen` initialise `attachments` with `{blobId,name,type,size}` entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload via `getDownloadUrl(..., ownerAccountId)` + `uploadBytes`, or block forward there).
|
||||
|
||||
- [x] **message/rfc822 attachments: no inline unwrap, no `.eml` preview — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the `.eml` chip; `.eml` attachments preview like an email (`components/email/email-viewer.tsx:1433-1507`, `1625-1636`; changelog 1.7.3, 1.8.1).
|
||||
- What RN does: the message renders "(empty message)" and the `.eml` chip can only be shared.
|
||||
- Fix hint: add `postal-mime` (pure JS, works in RN with a `TextDecoder` polyfill) and mirror the unwrap effect; render the parsed HTML through `EmailBodyView`.
|
||||
|
||||
- [x] **TNEF `winmail.dat` is not decoded — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (`lib/tnef.ts`, `components/email/email-viewer.tsx:1344-1431`).
|
||||
- What RN does: shows `winmail.dat` as an opaque attachment; the real files inside are unreachable.
|
||||
- Fix hint: `lib/tnef.ts` is pure `Uint8Array` code; copy it and feed `jmapClient.fetchBlobArrayBuffer`.
|
||||
|
||||
- [x] **Attachment chip list: no MIME-based fallback names, MDN/DSN report parts and non-inline cid images mishandled — fixed in d2ed27f** — `P3` — `partial`
|
||||
- What WEB does: unnamed parts get `Document.pdf`/`Email.eml`/`Attachment.<sub>` (`components/email/email-viewer.tsx:191-225`); `message/disposition-notification` and `message/delivery-status` parts are hidden (`1604-1606`); inline hiding requires `disposition === 'inline'` (`1601-1603`).
|
||||
- What RN does: unnamed → `'attachment'` (`src/screens/EmailThreadScreen.tsx:745`); report parts are listed; any `cid` image is hidden regardless of disposition (`719-721`), so an image attached with a Content-ID but `disposition: attachment` (common from some clients) disappears from the list even when the body does not reference it.
|
||||
- Fix hint: port `getAttachmentDisplayName`, add the two report-type filters, and check `disposition === 'inline'` (or that the body actually references the cid via `extractCidRefs`).
|
||||
|
||||
- [x] **No image thumbnails on chips, no "Download all" zip, no per-chip Preview vs Download choice — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (`components/email/email-viewer.tsx:2024-2102`, `2104-2161`, `3949-4085`; changelog 1.6.0, 1.7.5).
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
- [x] **Post-export action (archive/trash after "Export .eml") missing — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: `postExportAction` setting runs archive/trash after export (`components/email/email-viewer.tsx:2559-2571`).
|
||||
- What RN does: export only (`src/screens/EmailThreadScreen.tsx:632-640`).
|
||||
- Fix hint: add the setting and call `onArchive`/`onDelete` after `shareEmailEml` resolves.
|
||||
|
||||
### Actions from the viewer
|
||||
|
||||
- [x] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity) — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: passes the original's RFC `messageId` and `references`, stripped of brackets, as JMAP `inReplyTo`/`references` arrays (`lib/jmap/client.ts:3124-3194`; changelog 1.5.4 #234).
|
||||
- What RN does: `navigateCompose` sets `inReplyTo: email.id` (`src/screens/EmailThreadScreen.tsx:379`), and `sendEmail` writes it verbatim into `header:In-Reply-To:asText` / `header:References:asText` (`src/api/email.ts:823-826`). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply and `References` is never accumulated. `messageId`/`references` are not even fetched (see headers finding).
|
||||
- Fix hint: fetch `messageId`/`references`, pass `inReplyTo: email.messageId?.[0]` and `references: [...(email.references ?? []), email.messageId[0]]`, and send them as JMAP `inReplyTo`/`references` arrays (or `<…>`-wrapped `header:…:asMessageIds`).
|
||||
|
||||
- [x] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in 7f956d6** — `P1` — `bugfix-parity`
|
||||
- What WEB does: `buildReplyRecipients` — reply goes to `Reply-To` if present else `From`; reply-all adds original To/CC minus the user's own identities (exact and `+tag`-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (`lib/reply-recipients.ts:74-111`, `components/mail/mail-app.tsx:920`, `2940-2944`).
|
||||
- What RN does: `initialTo` = From (+ all original To on reply-all, including the user's own address), `initialCc` = all CC; `email.replyTo` is fetched but never passed (`src/screens/ComposeScreen.tsx:299-319`, `src/screens/EmailThreadScreen.tsx:366-382`). Replying to a newsletter with `Reply-To: support@…` mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself.
|
||||
- Fix hint: copy `lib/reply-recipients.ts` (pure), pass `replyToAddresses: email.replyTo` in the route param and the identities' emails as `ownEmails`.
|
||||
|
||||
- [x] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in 7f956d6** — `P2` — `bugfix-parity`
|
||||
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with `Name <email>` (`components/email/email-composer.tsx:462-500`, `lib/quote-header.ts:54-91`; changelog 1.7.1, 1.7.3, 1.8.1).
|
||||
- What RN does: `buildInitialHtml` supports `htmlBody` (`src/lib/compose-html.ts:79-83`) but the viewer only passes `body: plainTextBody(email)` (`src/screens/EmailThreadScreen.tsx:377`), and the header uses display name only (`compose-html.ts:31-35`) rather than WEB's `Name <email>` (#482).
|
||||
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`, cid refs rewritten to fetched data URIs or re-attached with the same `cid`) and switch `senderName` to the `Name <email>` form.
|
||||
|
||||
- [x] **Forward as attachment (.eml) missing — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: More menu "Forward as attachment" builds a `message/rfc822` attachment referencing the original blobId with a `{date}-{subject}.eml` name (`lib/forward-as-attachment.ts:38-58`, `components/mail/mail-app.tsx:2022-2060`; changelog 1.8.1).
|
||||
- What RN does: none.
|
||||
- Fix hint: add a More-sheet entry that opens `Compose` with `attachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}]` (depends on the forward-attachments fix).
|
||||
|
||||
- [x] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246) — fixed in 5b72c4d** — `P3` — `partial`
|
||||
- What WEB does: `resolveReplyFrom` — exact, then `+tag`-stripped, then same-domain catch-all with a From override (`lib/reply-identity.ts:197-256`).
|
||||
- What RN does: `identities.find(i => i.email === candidate.email)` (`src/screens/ComposeScreen.tsx:431-437`).
|
||||
- Fix hint: port `resolveReplyFrom`; the override needs a From override in `sendEmail`.
|
||||
|
||||
- [x] **Quick reply box under the message — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (`components/email/email-viewer.tsx:5066-5154`).
|
||||
- What RN does: none.
|
||||
- Fix hint: optional; a bottom input that calls `sendEmail` with `buildReplyRecipients` output and the plain-text quote.
|
||||
|
||||
- [x] **View source: no copy-to-clipboard; whole raw message loaded into a `<Text>` — fixed in d2ed27f** — `P3` — `partial`
|
||||
- What WEB does: synthetic source from the JMAP object with a Copy button (`components/email/email-viewer.tsx:5159-5208`, `lib/email-source.ts`). RN's raw RFC 822 view is actually more faithful.
|
||||
- What RN does: fetches the full blob as text and renders it in one selectable `Text` (`src/screens/EmailSourceScreen.tsx:24-35`, `68-70`); a 30 MB message will stall the JS thread.
|
||||
- Fix hint: add `Clipboard.setStringAsync`; cap display at e.g. 1 MB with a "Share full source" fallback.
|
||||
|
||||
- [x] **Add sender to contacts / edit contact from the viewer — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: contact sidebar with "Add to contacts"/"Edit" (`components/email/email-viewer.tsx:552-568`, `5336-5368`; changelog 1.7.4).
|
||||
- What RN does: none (see popover finding).
|
||||
- Fix hint: sheet entry navigating to `ContactForm` with prefilled name/email.
|
||||
|
||||
### Trusted senders / settings
|
||||
|
||||
- [x] **"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What WEB does: writes to the book only when `trustedSendersAddressBook` is on, else to the local list (`components/email/email-viewer.tsx:4646-4655`, `components/trusted-senders-modal.tsx:136-140`).
|
||||
- What RN does: always does both (`src/components/EmailBodyView.tsx:644-653`), and `addToTrustedSendersBook` creates the book on first add (`src/stores/contacts-store.ts:308-321`), so a user who left the setting off (default `false`, `src/stores/settings-store.ts:239`) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (`src/components/settings/ContentSendersSettings.tsx:19-21`, `123-144`), so book-trusted senders are invisible/unremovable there.
|
||||
- Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show `trustedSenderEmails` in the settings modal with remove support.
|
||||
|
||||
- [x] **S/MIME settings screen is a dead stub — fixed in 3d0d440** — `P3` — `rn-only-bug`
|
||||
- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (`components/email/email-viewer.tsx:923-937`).
|
||||
- What RN does: `SmimeSettings` renders empty mock arrays and buttons with no handlers (`src/components/settings/SmimeSettings.tsx:32-33`, `118-121`, `163-166`); there is no verification/decryption in the viewer.
|
||||
- Fix hint: hide the screen (or show a "not available in the mobile app" note) until a crypto path exists; treat viewer-side S/MIME as N/A.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Body isolation: sandboxed WebView with `default-src 'none'` CSP, `originWhitelist` about:blank, links opened externally, no cookies/storage/file access (`src/components/EmailBodyView.tsx:675-724`, `src/lib/email-html.ts:236-256`) — matches WEB's srcDoc iframe + CSP approach (1.6.7).
|
||||
- `hasMeaningfulHtmlBody` text-alternative preference (`src/lib/email-html.ts:353-362`) — same regex as WEB `lib/signature-utils.ts` (only the same-partId guard is missing, see P1 finding).
|
||||
- Dark mode: invert+hue-rotate with media re-invert, native-dark-mode detection, emoji re-invert pass, "Always view emails in light mode" setting (`src/lib/email-html.ts:167-185`, `197-199`, `src/components/EmailBodyView.tsx:64-157`, `499-502`) — only the CSS revision differs (see finding).
|
||||
- Plain-text path: escaped + linkified, native dark colours without inversion (`src/lib/email-html.ts:120-126`, `263-311`).
|
||||
- External content policy ask/block/allow, per-message "Load external content", "Trust sender" persisting locally and to the address book, trusted list from the address book and contacts (`src/components/EmailBodyView.tsx:480-535`, `643-672`).
|
||||
- cid inline images fetched with auth and injected as data URIs, transparent placeholder otherwise (`src/components/EmailBodyView.tsx:546-584`, `src/lib/email-html.ts:213-218`).
|
||||
- Shrink-to-fit of fixed-width emails + in-page pinch zoom/pan with pager/scroll locking (`src/components/EmailBodyView.tsx:159-460`) — RN-only, better than WEB's horizontal scroll (#409).
|
||||
- Mark read on open with delay setting (apart from the `-1` bug), optimistic star/unread/tag toggles, delete/archive/spam/not-spam/move scoped to the message's account, `no trash folder` alert (`src/screens/EmailThreadScreen.tsx:221-364`).
|
||||
- Prev/next navigation with prefetched neighbour panes and swipe (`src/screens/EmailThreadScreen.tsx:122-181`, `535-572`) — RN-only pager.
|
||||
- Skeleton while loading (`src/screens/EmailThreadScreen.tsx:845-886`).
|
||||
- Attachments: chips with size, show-all toggle, `hideInlineImageAttachments` setting, `attachmentPosition` setting, `mailAttachmentAction` preview/download, templated filenames + transforms identical to WEB (`src/lib/download-filename.ts` diff vs `lib/download-filename.ts` is whitespace/quotes only, minus `DEFAULT_BUNDLE_TEMPLATE`/`BUNDLE_TOKENS`), Downloads settings screen with live preview (`src/components/settings/DownloadsSettings.tsx`).
|
||||
- Attachment/blob/source/.eml fetches routed by owner account for shared/group messages (`src/screens/EmailThreadScreen.tsx:87-96`, `198-215`, `src/api/blob.ts:119-135`, `src/lib/email-export.ts`) — #847/#867 parity except the calendar banner.
|
||||
- Client-cert-aware downloads via `secureFetch`, streaming download via `File.downloadFileAsync` (`src/lib/email-export.ts:85-113`).
|
||||
- View source (raw RFC 822, shareable) and Export .eml with the email filename template (`src/screens/EmailSourceScreen.tsx`, `src/lib/email-export.ts:173-206`).
|
||||
- Calendar invitation: detection by MIME/extension, parse via `CalendarEvent/parse`, RSVP via import-then-`rsvpEvent` with `replyTo`/`organizerCalendarAddress`, cancelled notice, "no writable calendar" warning, `calendarInvitationParsingEnabled` setting (`src/components/email/CalendarInvitationBanner.tsx`, `src/lib/calendar-invitation.ts`).
|
||||
- Tag sheet from the viewer with keyword definitions (`src/screens/EmailThreadScreen.tsx:1033-1100`).
|
||||
|
||||
## N/A on mobile
|
||||
- Print (WEB `handlePrint`, `components/email/email-viewer.tsx:2621-2670`) — could be done with `expo-print` later, but not a parity requirement.
|
||||
- Copy permalink / deep link to a message (#733) — RN has no linking configuration; no `linking` prop in `App.tsx`.
|
||||
- Fullscreen reading toggle, Pro split panes, toolbar overflow measurement, plugin slots/hooks (`email-banner`, `email-detail-sidebar`, `onRenderEmailBody`, `onBeforeExternalLink`, `ui.rerenderEmail`), keyboard shortcuts, drag attachments out to the file system (#267), drag emails out as `.eml`, import `.eml` from the viewer menu (RN imports from the list screen instead).
|
||||
- Iframe-specific fixes: "Render the email body on DOM parse instead of iframe load" (#635), iframe flash/flicker fixes (1.5.4, 1.6.7), `blob:` in CSP `object-src`/`frame-src` for PDF previews (#253), pdf.js bytes vs `blob:` fetch (#871), RSC 307 loop (#919), iOS Safari viewport zoom on input focus (#838).
|
||||
- Demo-mode welcome pane, tour, contact sidebar panel layout (desktop only; the actions themselves are listed above).
|
||||
- S/MIME plugin UI (WEB core has none since 1.7.6); listed above only because RN ships a misleading stub.
|
||||
@@ -0,0 +1,313 @@
|
||||
# Composer, drafts, sending, identities, signatures, templates, scheduled send
|
||||
|
||||
## Summary
|
||||
RN has a working "compose / reply / forward / send now / send later" happy path (WebView rich editor with send-time readback, attachments + inline images, contact suggestions, identity sheet, HOLDFOR scheduling with a cancel list) but is far from parity with the 3,900-line web composer. The biggest gaps are structural: RN has **no drafts at all** (no autosave, no save-on-close, no opening/editing an existing draft), **no signatures** (identities carry `textSignature`/`htmlSignature` but the composer never uses them), **no Bcc**, **no template insertion in the composer**, **no Reply-To handling**, and **broken threading headers** (`In-Reply-To` is set to the JMAP email id instead of the RFC Message-ID). The send path also files the message into Sent *before* submission, so a failed `EmailSubmission/set` leaves a never-sent copy in Sent. Several web bugfixes (reply-all self-dedupe / #703, #570 attachment reminder on quoted text, #702 request timeout, unclosed angle brackets, subject-prefix dedupe) are not present in RN.
|
||||
|
||||
Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the native repo`.
|
||||
|
||||
## Findings
|
||||
|
||||
### Drafts
|
||||
|
||||
- [x] **No draft autosave / save-on-close — closing a compose loses everything** — fixed in 5b72c4d — `P1` — `missing`
|
||||
- What WEB does: debounced 2 s autosave to the server Drafts folder with `$draft`+`$seen` keywords (`components/email/email-composer.tsx:1648-1815` saveDraftOnce, `:1842-1883` timer; `lib/jmap/client.ts:2998-3060` createDraft), serialized against send (#303, `:1821-1837`), `beforeunload` best-effort save (`:1082-1091`), "Save / Discard / Cancel" close dialog (`:2347-2426`, `handleSaveDraftAndClose`, `handleDiscardAndClose`), toast when the save is refused so text is not dropped (`:2384-2401`, #702).
|
||||
- What RN does: the only close path is `onClose` in `src/screens/ComposeScreen.tsx:518-541`, an Alert with Cancel / **Discard** — no "Save draft" option and no server draft is ever created. The hardware back button / iOS swipe-back is not intercepted at all (no `beforeRemove` listener anywhere in `ComposeScreen.tsx` or `src/navigation/*`), so the dirty guard is bypassed entirely by the OS back gesture.
|
||||
- Fix hint: add a `createDraft(...)`-style call in `src/api/email.ts` (Email/set create into the `drafts`-role mailbox with `$draft`/`$seen`, destroy previous draft id only after the new create succeeds), call it from a debounced effect + from the close Alert ("Save draft" button), and register `navigation.addListener('beforeRemove', …)` to route back gestures through the same guard.
|
||||
|
||||
- [x] **Cannot open / edit an existing draft** — fixed in 5b72c4d — `P1` — `missing`
|
||||
- What WEB does: `handleEditDraft` in `components/mail/mail-app.tsx:1812` re-opens a draft with recipients, subject, HTML body, identity restored from `From` (`lib/reply-identity.ts:140-167` findDraftIdentityId), server attachments carried as blobId refs (`email-composer.tsx:577-597`, #849) and the signature embedded (`:520-535`, #848); sending destroys the old draft only after success (`lib/jmap/client.ts:3342-3360`) and clears the viewer when the sent draft was displayed (`mail-app.tsx:1645-1648`).
|
||||
- What RN does: `RootStackParamList.Compose` (`src/navigation/types.ts:7-22`) has no `draftId`/initial-body params; tapping a message in the Drafts folder opens the read-only `EmailThreadScreen` (no `role === 'drafts'` handling anywhere in `src/screens/EmailThreadScreen.tsx` or `src/screens/EmailListScreen.tsx`).
|
||||
- Fix hint: add a `draft` route param (id, to/cc/bcc, subject, htmlBody, attachments, from), an "Edit draft" action in the thread screen when the mailbox role is `drafts` or the email has `$draft`, and pass `draftId` into `sendEmail` so the old draft is destroyed after a successful submission. Depends on the previous item.
|
||||
|
||||
- [x] **Draft keeps In-Reply-To/References and reply-mode state** — fixed in 5b72c4d — `P2` — `missing`
|
||||
- What WEB does: drafts store the reply context in `ComposerDraftData` (`email-composer.tsx:109-132`) and threading headers are computed at send from the parent (`lib/email-threading.ts:30-51`).
|
||||
- What RN does: N/A today because drafts do not exist; when implementing drafts make sure `inReplyTo`/`references` (see the threading finding below) survive the round trip.
|
||||
- Fix hint: persist `header:In-Reply-To`/`header:References` on the draft Email/set and read them back via `inReplyTo`/`references` properties when reopening.
|
||||
|
||||
### Sending / EmailSubmission
|
||||
|
||||
- [x] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — fixed in ccbe67c — `P1` — `bug`
|
||||
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
|
||||
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
|
||||
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
|
||||
|
||||
- [x] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — fixed in 7f956d6 — `P1` — `rn-only-bug`
|
||||
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
|
||||
- What RN does: `src/screens/EmailThreadScreen.tsx:379` passes `inReplyTo: email.id` (the JMAP id, e.g. `a1b2c3`), never `references`; `src/api/email.ts:823-826` writes that value verbatim into `header:In-Reply-To:asText` and `header:References:asText`. `EMAIL_FULL_PROPERTIES` (`src/api/email.ts:11-15`) does not even request `messageId`/`references`, and the RN `Email` type (`src/api/types.ts:65-87`) has no such fields. Every RN reply therefore breaks threading in recipients' clients and emits a syntactically invalid `In-Reply-To`.
|
||||
- Fix hint: add `'messageId', 'inReplyTo', 'references'` to `EMAIL_FULL_PROPERTIES` and the `Email` type, port `computeReplyThreadingHeaders`, and send `inReplyTo`/`references` as JMAP array properties (bare msg-ids) instead of raw `header:*:asText` strings. Only replies should continue the chain; forwards should not (WEB `email-composer.tsx:2142-2145`).
|
||||
|
||||
- [x] **No request timeout — a stalled send hangs the composer forever** — fixed in ccbe67c — `P2` — `bugfix-parity` (changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)")
|
||||
- What WEB does: `JMAPClient.REQUEST_TIMEOUT_MS = 30_000` (`lib/jmap/client.ts:629`, `:811`, `:830-843`) raising `RequestTimeoutError`; the composer shows a dedicated "may already have gone out, check Sent" message instead of "send failed" so users do not re-send duplicates (`email-composer.tsx:2315-2323`).
|
||||
- What RN does: `jmapClient.request` (`src/api/jmap-client.ts:405-445`) uses `secureFetch` with no `AbortSignal`/deadline; `performSend` (`ComposeScreen.tsx:906-982`) leaves `sending=true` until the promise settles, so the Send button spins indefinitely on a stalled connection.
|
||||
- Fix hint: wrap `secureFetch` in an `AbortController` + `setTimeout` (30 s) in `jmapClient.request` (or a `timeoutMs` option used by `sendEmail`), throw a distinguishable `RequestTimeoutError`, and show a "may already be sent - check Sent" alert in `performSend`'s catch.
|
||||
|
||||
- [x] **Undo-send delay has no undo toast / "send now"** — fixed in 5b72c4d — `P2` — `partial`
|
||||
- What WEB does: after a delayed send `pendingUndoSend` is set (`stores/email-store.ts:1700-1712`) and a toast offers Undo / Send now (changelog 1.7.0 "'Send now' action on the send-delay toast"; `email-store.ts:4381` cancelUndoSend).
|
||||
- What RN does: `onSend` (`ComposeScreen.tsx:825-828`) applies `sendDelaySeconds` as HOLDFOR and `performSend` deliberately stays silent for the undo window (`:961-963` comment: "invisible unless the user cancels from the Scheduled view"). There is an `UndoSnackbar` component (`src/components/UndoSnackbar.tsx`) but it is not used for send. The user has to know to open the Scheduled screen within the 5-30 s window.
|
||||
- Fix hint: after `sendEmail` returns `{ scheduled: true, emailSubmissionId }` show `UndoSnackbar` for `sendDelaySeconds` with Undo → `cancelScheduledSend(id)` and "Send now" → EmailSubmission/set update of the envelope without HOLDFOR (or cancel + resend), mirroring `rescheduleEmailSubmission` in `lib/jmap/client.ts:7985`.
|
||||
|
||||
- [x] **Double-submit guard relies on async state** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog 1.6.x "Guard Send against double-submit")
|
||||
- What WEB does: synchronous `isSendingRef` re-entry guard (`email-composer.tsx:1980-1986`, `:2007`, `:2078-2079`).
|
||||
- What RN does: `performSend` checks `canSend` which includes `!sending` (`ComposeScreen.tsx:508-516`, `:883`), but `setSending(true)` only happens after two awaits (`getHtml` at `:892`, attachment reminder at `:905`), so two quick taps both pass the guard and submit twice.
|
||||
- Fix hint: add a `sendingRef = useRef(false)` set synchronously at the top of `performSend`, reset in `finally`.
|
||||
|
||||
- [x] **Reply / forward does not mark the original `$answered` / `$forwarded`** — fixed in 7f956d6 — `P2` — `missing`
|
||||
- What WEB does: after a successful reply/forward the original gets `$answered` or `$forwarded` (`components/mail/mail-app.tsx:1663-1673`), routed to the owning account.
|
||||
- What RN does: nothing — `grep -F '$answered'` over `src/` returns no hits; `performSend` (`ComposeScreen.tsx:941-974`) has no access to the original email id (`replyTo` params in `src/navigation/types.ts:10-19` carry no `emailId`).
|
||||
- Fix hint: add `originalEmailId`/`jmapAccountId` to the `replyTo` route param and call the existing keyword primitive (`setEmailKeywords` via `applyOrQueue` in `src/stores/outbox-store.ts`) after the send resolves.
|
||||
|
||||
- [x] **Identity `replyTo` / `bcc` are never applied to outgoing mail** — fixed in 7f956d6 — `P2` — `missing`
|
||||
- What WEB does: `sendEmail` copies the identity's `replyTo` onto the message (`lib/jmap/client.ts:3167-3172`, `:3184`); identity form lets users edit Reply-To and Bcc (`components/identity/identity-form.tsx:39-41`, `:66-70`, `:132-133`).
|
||||
- What RN does: RN `Identity` type has `replyTo`/`bcc` (`src/api/types.ts:136-145`) but `sendEmail` (`src/api/email.ts:788-796`) never sets `replyTo`, and no composer Reply-To field exists; `IdentitySettings.tsx:209-215` edits only name/email/textSignature.
|
||||
- Fix hint: in `sendEmail` set `replyTo: identity.replyTo` and merge `identity.bcc` into `bcc`; pass the full `Identity` (not only `primaryIdentity.id`) from `performSend`.
|
||||
|
||||
- [x] **Read-receipt (MDN) request missing** — fixed in 5b72c4d — `P3` — `missing`
|
||||
- What WEB does: composer toggle (`email-composer.tsx:3053-3066`), `requestReadReceiptDefault` setting (`stores/settings-store.ts:351`), header `Disposition-Notification-To` set on create (`lib/jmap/client.ts:3201-3204`).
|
||||
- What RN does: no toggle, no setting, header never set.
|
||||
- Fix hint: add a toolbar toggle + `requestReadReceipt` option to `sendEmail` that writes `header:Disposition-Notification-To:asText`.
|
||||
|
||||
- [x] **Client-side Message-ID not generated** — fixed in ccbe67c — `P3` — `partial`
|
||||
- What WEB does: `generateMessageId(fromEmail)` with the sender's domain, guarded for insecure origins (`lib/jmap/client.ts:544-575`, `:3192`; changelog 1.9.0 fix).
|
||||
- What RN does: `sendEmail` sets no `messageId`, relying on the server; RN's `src/lib/uuid.ts:6-20` already has a guarded UUID generator (Hermes fallback) so the crash class of the web fix does not apply.
|
||||
- Fix hint: optional — set `messageId: [`${generateUUID()}@${domain}`]` in `sendEmail` so the id is known before the response (useful once drafts/threading exist).
|
||||
|
||||
- [ ] **Sending from shared/group accounts unsupported** — `P3` — `missing` (partly N/A) — deferred: RN is single-active-account; envelope/identity routing for shared accounts not implemented
|
||||
- What WEB does: routes the submission to the identity's owning account (#461, `email-composer.tsx:2258-2274`, `lib/jmap/client.ts:3131-3134` targetAccountId) and preselects the shared folder's identity (`lib/reply-identity.ts:114-128`).
|
||||
- What RN does: `ComposeScreen.tsx:267-272` explicitly always uses the user's own Sent ("composing on behalf of a shared account isn't supported"), `getIdentities` (`src/api/identity.ts:5-12`) only queries the primary account. Replying from a shared-folder message sends as the reaching login.
|
||||
- Fix hint: low priority; if implemented, query `Identity/get` for the shared `accountId`, submit with that `accountId`, and file into that account's Sent.
|
||||
|
||||
- [x] **Dead duplicate send implementation** — fixed in 42ab246 — `P3` — `rn-only-bug`
|
||||
- What RN does: `src/api/submission.ts:5-64` is an older `sendEmail` (no HOLDFOR, no result) that nothing imports (`grep "api/submission"` → no hits); `ComposeScreen.tsx:38` imports `sendEmail` from `src/api/email.ts`.
|
||||
- Fix hint: delete `src/api/submission.ts` (and its test if any) to avoid someone wiring the stale path.
|
||||
|
||||
### Recipients
|
||||
|
||||
- [x] **No Bcc field** — fixed in 5b72c4d — `P2` — `missing`
|
||||
- What WEB does: Bcc chip input toggled next to To (`email-composer.tsx:2768-2792`, `:2825-2851`), included in send/draft/dirty-check.
|
||||
- What RN does: only To and Cc (`ComposeScreen.tsx:346-349` state, `:1058-1149` UI); `sendEmail` accepts `bcc` (`src/api/email.ts:772`) but the screen never passes it.
|
||||
- Fix hint: clone the Cc block (`ccRecipients`/`ccInput`/`ccVisible`) as Bcc, include it in `alreadySelected`, `commitTyped`, `isDirty`, and the `sendEmail` call.
|
||||
|
||||
- [x] **Reply-To header ignored when replying** — fixed in 7f956d6 — `P2` — `bugfix-parity` (changelog 1.9.0 "Honour an external Reply-To even on a self-sent message"; 1.7.x #703)
|
||||
- What WEB does: `buildReplyRecipients` (`lib/reply-recipients.ts:74-111`) replies to `replyToAddresses` when present (RFC 5322), falls back to From, and handles self-sent messages (`isSelfSent`, `:52-54`) by addressing the original To/Cc instead of yourself.
|
||||
- What RN does: `initialTo` (`ComposeScreen.tsx:291-312`) always uses `replyTo.from`; `EmailThreadScreen.tsx:370-381` does not pass `email.replyTo` although it is fetched (`EMAIL_FULL_PROPERTIES` includes `replyTo`). Replying to a mailing-list/ticket mail with Reply-To goes to the wrong address; replying to your own sent message mails yourself.
|
||||
- Fix hint: pass `replyToAddresses: email.replyTo` in the route param and port `buildReplyRecipients` (pure TS, no DOM) into `src/lib/`.
|
||||
|
||||
- [x] **Reply-all does not drop the user's own identities / duplicates** — fixed in 7f956d6 — `P2` — `bugfix-parity`
|
||||
- What WEB does: reply-all filters every own identity address (exact and `+tag`-stripped) out of To/Cc (`lib/reply-recipients.ts:39-45`, `:104-110`) and `expandRecipients` dedupes case-insensitively across the whole list (`lib/email-composer-utils.ts:425-437`).
|
||||
- What RN does: `initialTo` (`ComposeScreen.tsx:304-310`) adds every original `to` except the From; `initialCc` (`:314-319`) copies `cc` unfiltered — the user's own address lands in To/Cc of every reply-all, and the same address can appear in both To and Cc (`alreadySelected` at `:371-374` only guards suggestions).
|
||||
- Fix hint: filter against the identities list (already loaded at `:406-419`, but note it loads asynchronously — compute recipients after identities arrive or filter at send time) and dedupe across To/Cc/Bcc in `commitTyped`.
|
||||
|
||||
- [x] **Cc addresses are not validated; `Name <addr` recovery and quoted names missing** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog 1.9.0 "Split recipient lists whose angle brackets never close", 1.7.x #672)
|
||||
- What WEB does: `splitRecipients` is quote/angle-aware and recovers from an unclosed `<` (`lib/email-composer-utils.ts:275-321`), `splitPastedRecipients` keeps display names, unwraps `<addr>` tokens and returns leftovers to the input (`:470-521`), `splitMailbox` tolerates a missing `>` (`lib/rfc5322-mailbox.ts:49-58`), `isValidEmail` is RFC 5322 with injection guards (`lib/validation.ts:4-28`).
|
||||
- What RN does: `parseRecipients` (`ComposeScreen.tsx:69-84`) splits on `[,;\n]` regardless of quotes, requires a closed `>` (else the whole string becomes the "email"), and `hasValidRecipients` (`:504`) only checks **To** against a loose `EMAIL_RE` (`:66`) — an invalid Cc chip is sent to the server and fails there. `"Doe, John" <j@x>` becomes two chips.
|
||||
- Fix hint: port `splitRecipients`/`parseRecipient`/`splitPastedRecipients` and `isValidEmail` (all DOM-free) into `src/lib/recipients.ts`; validate Cc/Bcc in `canSend` and mark invalid chips red.
|
||||
|
||||
- [x] **Contact groups not offered as recipients; no server (Sent-folder) search** — fixed in 5b72c4d — `P3` — `missing`
|
||||
- What WEB does: groups appear in autocomplete as a single expandable chip (`email-composer.tsx:1214-1245`, changelog 1.7.0), plus an on-demand "search the server" row backed by Sent (`:1189-1212`, changelog 1.6.x).
|
||||
- What RN does: suggestions come only from `individuals` (`ComposeScreen.tsx:367-392`, groups filtered out); groups are reachable only via `GroupDetailScreen.tsx:83` prefill.
|
||||
- Fix hint: include groups in `suggestions` and expand members on pick (use `isGroup`/members from `contacts-store`); server search is optional.
|
||||
|
||||
- [x] **Recipient chips show no avatar / no "Name <addr>" tooltip; no chip editing** — fixed in 5b72c4d — `P3` — `partial` (N/A for drag-reorder)
|
||||
- What WEB does: avatars in suggestions, chip context menu (copy, add to contacts, move To/Cc/Bcc) (`email-composer.tsx:3860-3895`).
|
||||
- What RN does: `RecipientChip` (`ComposeScreen.tsx:111-124`) shows name-or-email with an X only.
|
||||
- Fix hint: long-press menu with "Move to Cc/Bcc", "Copy address", "Add to contacts".
|
||||
|
||||
### Identity / From
|
||||
|
||||
- [x] **Sub-addressing (`user+tag@`) missing** — fixed in 5b72c4d — `P3` — `missing`
|
||||
- What WEB does: `SubAddressHelper` next to From with tag suggestions (`components/identity/sub-address-helper.tsx`, `lib/sub-addressing.ts`), configurable delimiter (`stores/settings-store.ts:347`), identity matching strips `+tag` (`lib/reply-identity.ts:18-31`).
|
||||
- What RN does: none; identity matching in `ComposeScreen.tsx:424-441` is exact-address only.
|
||||
- Fix hint: port `lib/sub-addressing.ts` (pure) and add a small "+tag" affordance in the From row.
|
||||
|
||||
- [x] **From override / catch-all alias on an owned domain missing** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x #246)
|
||||
- What WEB does: `resolveReplyFrom` (`lib/reply-identity.ts:197-256`) picks an identity or, when the message arrived at an unknown address on a domain the user owns, pre-fills a From override sent through the identity's envelope (`email-composer.tsx:2598-2708` UI, `:2113-2121` envelope).
|
||||
- What RN does: From is always an identity (`ComposeScreen.tsx:908`).
|
||||
- Fix hint: port `resolveReplyFrom`; allow an override name/email pair and set `submissionCreate.envelope.mailFrom` to the identity (RN already builds envelopes for HOLDFOR at `src/api/email.ts:828-844`).
|
||||
|
||||
- [x] **Identity preselection does not consider Bcc or `+tag` and never uses the primary-identity setting** — fixed in c659b54 — `P3` — `partial`
|
||||
- What WEB does: exact → base (`+tag` stripped) match over to/cc/bcc (`lib/reply-identity.ts:38-68`), self-sent keeps the sending identity (`email-composer.tsx:812-822`), synced `preferredPrimaryId` default identity (#507, `stores/identity-store.ts:18,77`; `hooks/use-identity-sync.ts`).
|
||||
- What RN does: `ComposeScreen.tsx:424-441` matches exact addresses in to/cc only (no bcc, no base match), default = identity matching the active account email else `identities[0]`; no "default identity" setting exists in `IdentitySettings.tsx`.
|
||||
- Fix hint: port `findReplyIdentityId`; add a "Use as default" action in `IdentitySettings` persisted in `settings-store`.
|
||||
|
||||
- [x] **Identity display name not sanitized on create/update** — fixed in ccbe67c — `P3` — `bugfix-parity` (changelog 1.5.x "Sanitize identity display name to prevent invalid From headers")
|
||||
- What WEB does: `sanitizeIdentityDisplayName` before every Email/set (`lib/jmap/client.ts:3034`, `:3181`) and `sanitizeDisplayName` (`lib/rfc5322-mailbox.ts:27-30`).
|
||||
- What RN does: `IdentitySettings.tsx:253-278` sends the raw name; `performSend` uses `primaryIdentity.name` verbatim (`ComposeScreen.tsx:908`). A name like `Jane <jane@x>` or one containing a newline produces a malformed From.
|
||||
- Fix hint: port `sanitizeDisplayName` and apply it in `saveDraft` (IdentitySettings) and in `sendEmail`'s `from`.
|
||||
|
||||
- [x] **Identity editor lacks HTML signature, Reply-To, Bcc fields and size caps** — fixed in c659b54 — `P3` — `partial` — note: the HTML signature is edited as raw HTML in a monospace field (no rich preview); links/images are sanitized on save
|
||||
- What WEB does: `components/identity/identity-form.tsx` edits name, Reply-To list, Bcc list, text + HTML signature with a 2047-byte cap hint (`:12`, `:39-41`, `:66-70`, `:126-133`), sanitizes HTML.
|
||||
- What RN does: `IdentitySettings.tsx:209-215` DraftIdentity has name/email/textSignature only.
|
||||
- Fix hint: add multiline `htmlSignature` (or keep text-only but at least Reply-To/Bcc comma lists validated with the ported `isValidEmail`).
|
||||
|
||||
- [ ] **Identity sync (visibility/interval refresh) missing** — `P3` — `partial` — deferred: composer refetches on every mount; acceptable per finding
|
||||
- What WEB does: `hooks/use-identity-sync.ts` refreshes identities on tab focus and every 30 min.
|
||||
- What RN does: `ComposeScreen.tsx:406-419` refetches on every composer mount (adequate), but `settings-store.identities` (`src/stores/settings-store.ts:424-431`) only refreshes when the Identities settings pane mounts. Acceptable; listed for completeness.
|
||||
- Fix hint: optional `AppState` foreground refresh.
|
||||
|
||||
### Signatures
|
||||
|
||||
- [x] **Signatures are never inserted or sent** — fixed in 5b72c4d — `P2` — `missing`
|
||||
- What WEB does: compose embeds the signature as an editable block (`email-composer.tsx:254-275` buildEmbeddedSignatureHtml, `:437-443`), replies place it above or below the quote per `signaturePosition` (`:345-353`, `stores/settings-store.ts:349-350`), `-- ` separator toggle, plain-text fallback (`lib/signature-utils.ts:18-51`), alias without signature falls back to the primary identity's (`email-composer.tsx:688-693`), identity switch swaps the block (`:702-775`), double-click unlocks the atom for editing (`components/email/signature-block.ts:112-120`, `:240`), drafts keep it (#848).
|
||||
- What RN does: `Identity.textSignature`/`htmlSignature` exist (`src/api/types.ts:142-143`) and are editable in settings, but `buildInitialHtml` (`src/lib/compose-html.ts:64-106`) and `performSend` (`ComposeScreen.tsx:906-960`) never reference them. Users who set a signature in RN or on the web get none on mobile.
|
||||
- Fix hint: in `buildInitialHtml` append `<p>-- </p><div data-signature>…</div>` (sanitized `htmlSignature`, else escaped `textSignature`) after the empty paragraph for compose and above/below the quote for replies according to a new `signaturePosition` setting; re-splice on identity change via `editorRef.setHtml`. The WebView editor is a plain contenteditable, so no "unlock" mechanism is needed (mobile equivalent of 3d1bdf09 is simply "always editable").
|
||||
|
||||
- [x] **Signature settings (position, separator) absent** — fixed in c659b54 — `P3` — `missing`
|
||||
- What WEB does: `components/settings/composing-settings.tsx:84-96` (position above/below quote, `-- ` separator).
|
||||
- What RN does: `ComposingSettings.tsx:13-20` exposes auto-select identity, send delay, attachment reminder only; `settings-store.ts:131-137` has no signature keys.
|
||||
- Fix hint: add `signaturePosition` / `signatureSeparatorEnabled` to `settings-store` and the Composing pane (do this together with the signature finding).
|
||||
|
||||
### Editor / body
|
||||
|
||||
- [x] **Reply / forward quotes plain text only — HTML layout and inline images are lost** — fixed in 5b72c4d — `P2` — `missing` (changelog #163, #543, "Editable, layout-preserving quote island")
|
||||
- What WEB does: `getQuoteBodies` picks the right part by MIME (`lib/email-composer-utils.ts:36-55`, #649), quotes the sanitized HTML as an editable island (`email-composer.tsx:482-496`), rewrites `cid:` images and hydrates them as data URLs so they render and are re-attached on send (`:873-957`, `:1938-1978`).
|
||||
- What RN does: `EmailThreadScreen.tsx:59-65` `plainTextBody` returns the text part or `preview`, and `navigateCompose` passes only `body` (`:377`); `buildInitialHtml` supports `htmlBody` (`compose-html.ts:76-80`) but is never given one. Replies to HTML-only mail quote whatever `textBody[0]` holds (for HTML-only messages RFC 8621 exposes the HTML source there — raw tags in the quote, the #649 bug) and inline images vanish.
|
||||
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`/`email-html.ts`) from the thread screen, port `getQuoteBodies`' MIME routing, and for `cid:` images fetch the blob (`getDownloadUrl` in `src/api/blob.ts:119`) as base64, insert with `data-cid`, and add the part as `disposition: 'inline'` on send (RN's `rewriteInlineImages` at `compose-html.ts:143-161` already handles `data-cid`).
|
||||
|
||||
- [x] **Forward drops the original attachments** — fixed in 7f956d6 — `P1` — `missing`
|
||||
- What WEB does: forward seeds the composer with the original's attachments as blobId refs (`email-composer.tsx:598-617`), skipping cid-embedded images, and sends them along (`:2200-2213`); "Forward as attachment" also exists (`lib/forward-as-attachment.ts`).
|
||||
- What RN does: `navigateCompose` (`EmailThreadScreen.tsx:366-382`) passes no attachments and `Compose` params have no attachment field; `attachments` state starts empty (`ComposeScreen.tsx:354`). A forwarded invoice arrives without the invoice, silently.
|
||||
- Fix hint: add `attachments: email.attachments` (blobId/name/type/size, filter `disposition==='inline'` images referenced by the body) to the route param and seed `AttachmentEntry`s with `blobId` set and `uploading:false`; `OutgoingAttachment` already accepts blobIds from another message in the same account.
|
||||
|
||||
- [x] **Quote header is English-only and shows the sender name without address** — fixed in 5b72c4d — `P3` — `partial` (changelog #482, i18n quote header)
|
||||
- What WEB does: localized `quote_header.*` labels and `On {date}, {from} wrote:` (`lib/quote-header.ts:54-91`), sender rendered as `Name <email>`.
|
||||
- What RN does: `compose-html.ts:84-105` hardcodes "Forwarded message", "From/Date/Subject", "On …, X wrote:" and `senderName` (`:30-33`) returns name **or** email, never both. Locale files exist under `locales/<lang>/`.
|
||||
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
|
||||
|
||||
- [x] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
|
||||
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
|
||||
- What RN does: `ComposeScreen.tsx:321-328` only tests `^re:`/`^fwd?:`; "AW: foo" becomes "Re: AW: foo", "Fwd: foo" replied becomes "Re: Fwd: foo".
|
||||
- Fix hint: copy `lib/subject-prefix.ts` verbatim (pure TS) and use the locale's prefix strings.
|
||||
|
||||
- [x] **Attachment reminder scans the quoted original** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #570)
|
||||
- What WEB does: `extractUserAuthoredText` strips blockquotes/quoted island and everything after the forwarded separator before keyword matching (`lib/email-composer-utils.ts:175-210`, used at `email-composer.tsx:2060-2066`).
|
||||
- What RN does: `passesAttachmentReminder` (`ComposeScreen.tsx:764-791`) runs `htmlToPlainText` over the **whole** editor HTML including the `<blockquote>`/forward block built by `buildInitialHtml`, so replying to any mail that mentions "attached" fires the warning.
|
||||
- Fix hint: strip `<blockquote>…</blockquote>` and everything from the forwarded separator before matching (regex is fine, no DOM needed).
|
||||
|
||||
- [x] **Empty subject blocks Send instead of confirming** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #684)
|
||||
- What WEB does: confirm dialog with "Don't ask again" (`email-composer.tsx:2046-2051`, `:3228-3264`, setting `emptySubjectWarningEnabled`).
|
||||
- What RN does: `canSend` requires `subject.trim().length > 0` (`ComposeScreen.tsx:513`) — the Send button is simply disabled with no explanation.
|
||||
- Fix hint: drop the subject condition from `canSend` and add an Alert ("Send without subject?") gated by a new `emptySubjectWarningEnabled` setting.
|
||||
|
||||
- [x] **Toolbar coverage: no text color, tables, font, horizontal rule, text-direction** — fixed in 5b72c4d — `P3` — `partial`
|
||||
- What WEB does: Tiptap with Underline, Link, TextAlign, TextStyle+Color, ResizableImage, Table (`components/email/rich-text-editor.tsx:191-225`), auto text direction (changelog 1.7.0), tables (#236).
|
||||
- What RN does: bold/italic/underline/strike, H1/H2, lists, blockquote, align, link, image, clear, undo/redo (`ComposeScreen.tsx:1190-1240`; commands in `src/components/RichTextEditor.tsx:8-24`). No `dir="auto"` on the editor (`src/lib/editor-html.ts:244`).
|
||||
- Fix hint: `execCommand('foreColor')` + a small palette, `insertHTML` for a basic table; set `dir="auto"` on `#editor`.
|
||||
|
||||
- [x] **Plain-text mode still renders the rich editor and derives text from HTML (links lose their href)** — fixed in c659b54 — `P3` — `partial` — note: the toggle now also lives under Composing; the duplicate in ReadingSettings (area 08 file, uncommitted edits there) was left in place
|
||||
- What WEB does: plain-text mode swaps to a `<textarea>` (`email-composer.tsx:2875-2891`) and `htmlToPlainText` renders links as `text <href>` (`lib/html-to-text.ts:134-146`); the setting lives under Composing (#422).
|
||||
- What RN does: `plainTextMode` only suppresses the HTML part at send (`ComposeScreen.tsx:951`); the user still sees formatting buttons; `htmlToPlainText` (`compose-html.ts:113-135`) strips `<a>` tags entirely so pasted links disappear from the text/plain alternative (which is the **only** part in plain-text mode). The toggle sits in `ReadingSettings.tsx:262` instead of Composing.
|
||||
- Fix hint: hide the format bar and use a `TextInput multiline` when `plainTextMode`; in `htmlToPlainText` replace `<a href="X">T</a>` with `T <X>` before stripping tags; move the toggle to `ComposingSettings`.
|
||||
|
||||
- [x] **Inline image insert: no camera, no paste, no multi-select; not offered for file attachments either** — fixed in 5b72c4d — `P3` — `partial`
|
||||
- What WEB does: drag/drop and paste of images embed as data URLs + cid (`email-composer.tsx:1519-1551`, changelog #163).
|
||||
- What RN does: `insertInlineImage` (`ComposeScreen.tsx:693-732`) library only, single image; `pickFileAttachment` (`:646-673`) picks one file at a time; no `launchCameraAsync`.
|
||||
- Fix hint: add a "Camera" option to the attach Alert (`ImagePicker.launchCameraAsync`) and allow multiple in `pickFileAsync` where supported.
|
||||
|
||||
- [x] **Send-time editor readback (RN #9/#11) — verified robust, one edge** — fixed in 5b72c4d — `P3` — `rn-only-bug`
|
||||
- What RN does: `getHtml` rejects after 2 s when the page script is dead (`src/components/RichTextEditor.tsx:143-152`) and `performSend` aborts loudly (`ComposeScreen.tsx:890-902`); `editor-html.ts` is escape-free and unit-tested (`src/lib/__tests__/editor-html.test.ts`). The Android release fix (c5e5092) has since been superseded by the inline `source={{ html }}` + `originWhitelist={['about:blank']}` approach (`RichTextEditor.tsx:198-230`); `android/app/src/main/assets/editor.html` from that commit is no longer referenced (`grep editor.html` → only the TS module). Edge: `removeAttachment` (`ComposeScreen.tsx:553-571`) rewrites `bodyHtml` from the last `change` message rather than a live readback, so if the bridge lags it can `setHtml` a stale body and drop the last keystrokes.
|
||||
- Fix hint: use `await editorRef.current.getHtml()` before stripping the `<img data-cid>`; delete the orphaned `editor.html` asset.
|
||||
|
||||
### Attachments / upload
|
||||
|
||||
- [x] **No byte progress, cancel does not abort the transfer, no size-limit check** — fixed in 5b72c4d — `P2` — `partial` (changelog 1.9.0 "Real byte progress…cancel aborts", 1652a0ec "respect server limits")
|
||||
- What WEB does: XHR upload with `onProgress` + `AbortSignal` (`lib/jmap/client.ts:3954-3990`), chip progress bar (`email-composer.tsx:2951-2965`), `abortController.abort()` on remove (`:1605-1609`), `getMaxSizeUpload()` from the core capability (`lib/jmap/client.ts:4180-4181`; used for files at `components/files/files-app.tsx:311-336`).
|
||||
- What RN does: `uploadBlob` (`src/api/blob.ts:5-62`) reads the whole file into memory (`new File(uri).bytes()`) and `fetch`es it with no progress or signal; `AttachmentChip` shows a spinner + "Uploading..." (`ComposeScreen.tsx:174-192`); `removeAttachment` (`:553-571`) removes the chip while the upload keeps running; no `maxSizeUpload`/`maxSizeAttachmentsPerEmail` check — a 60 MB video from the picker fails only after the full upload with a raw `Upload failed: 413`.
|
||||
- Fix hint: check `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` (and the mail capability's `maxSizeAttachmentsPerEmail` for the running total) before starting; use `expo-file-system` `createUploadTask`/`uploadAsync` with progress callback and `cancelAsync()`, keep the handle on `AttachmentEntry`.
|
||||
|
||||
- [x] **Attachment chip has no preview / open** — fixed in 5b72c4d — `P3` — `missing`
|
||||
- What WEB does: click a chip to preview via `FilePreviewModal` (`email-composer.tsx:1615-1646`).
|
||||
- What RN does: chip is display-only (`ComposeScreen.tsx:164-200`).
|
||||
- Fix hint: on press open the local `uri` with `expo-sharing`/`Linking`.
|
||||
|
||||
### Scheduled send
|
||||
|
||||
- [x] **Scheduled screen: cancel only — no reschedule, no "send now", no edit** — fixed in c659b54 — `P2` — `partial`
|
||||
- What WEB does: scheduled view with send-now button (changelog 1.6.x), reschedule (`stores/email-store.ts:4353` rescheduleScheduledEmail, `lib/jmap/client.ts:7985`), cancel-for-edit that reopens the composer (`email-store.ts:4326`), listing routed to the owning account (#874).
|
||||
- What RN does: `src/screens/ScheduledScreen.tsx:51-78` offers only "Cancel send"; `src/api/email.ts:902-988` has `listScheduledEmails`/`cancelScheduledSend` only.
|
||||
- Fix hint: add `rescheduleScheduledSend(submissionId, emailId, identityId, holdFor)` = `EmailSubmission/set { update: {id: {undoStatus:'canceled'}}, create: {replacement: {emailId, identityId, envelope(HOLDFOR)}} }` and an edit path once drafts exist (cancel → open composer prefilled from the Email).
|
||||
|
||||
- [x] **Scheduled screen strings are hard-coded English** — fixed in c659b54 — `P3` — `rn-only-bug`
|
||||
- What RN does: `ScheduledScreen.tsx:52-56`, `:69`, `:85` (`toLocaleString()` without locale/12h setting), `:118` "Scheduled", `:20`/`:88` "(no recipient)"/"(no subject)" — no `t()` calls at all, whereas `ComposeScreen` is localized.
|
||||
- Fix hint: wrap in `useLocaleStore().t` and format with the shared date helper (`src/lib/date-format.ts`).
|
||||
|
||||
- [x] **Scheduled preset times ignore the user's 12/24h format** — fixed in c659b54 — `P3` — `rn-only-bug`
|
||||
- What RN does: `ComposeScreen.tsx:966-970` and `:1321-1326` use `toLocaleString(undefined, …)` / `toLocaleString()` rather than the `timeFormat` setting the quote header already respects (`compose-html.ts:37-51`).
|
||||
- Fix hint: reuse `formatQuoteDate`-style formatting with `timeFormat`.
|
||||
|
||||
### Templates
|
||||
|
||||
- [x] **No way to insert a template in the RN composer** — fixed in 5b72c4d — `P2` — `missing`
|
||||
- What WEB does: template picker + "save as template" buttons in the composer (`email-composer.tsx:3030-3049`), `t` shortcut, insertion at the caret keeping typed text and the signature (#539/#540/#621, `:1278-1373`), placeholder auto-fill (`lib/template-utils.ts:46-62`, `components/templates/placeholder-fill-modal.tsx`), default recipients / identity per template (`lib/template-types.ts:8-13`).
|
||||
- What RN does: templates can be created/edited/imported/exported in `src/components/settings/TemplateSettings.tsx` and stored in `src/stores/templates-store.ts`, but `ComposeScreen.tsx` never imports `useTemplatesStore` — the feature is unreachable from the composer.
|
||||
- Fix hint: add a toolbar button that opens a bottom sheet listing `templates`; on pick set subject if empty and `editorRef.insertHtml(escaped body)` (the `insertHtml` bridge exists in `editor-html.ts:368-373`); substitute `{{date}}`, `{{day_of_week}}`, `{{sender_name}}` via a port of `getAutoFilledPlaceholders` and prompt for the rest.
|
||||
|
||||
- [x] **Template model lacks isHTML, category UI, favourites, default recipients, identity; export format differs** — fixed in c659b54 — `P3` — `partial`
|
||||
- What WEB does: `lib/template-types.ts:1-17`, export envelope `{version:1,type:'webmail-templates',…}` (`lib/template-utils.ts:148-156`), import validation (`:286-351`), favourites/recent (`stores/template-store.ts`).
|
||||
- What RN does: `templates-store.ts:307-316` has name/subject/body/category/isFavorite only; `exportAll` (`:395-397`) emits `{version:1,templates}` **without** `type:'webmail-templates'`, so the web `importTemplates` rejects an RN export with `invalid_type` (`lib/template-utils.ts:302-304`); RN import (`:399-434`) accepts web exports. Category/favourite are not editable in `TemplateSettings.tsx:68-81`.
|
||||
- Fix hint: add `type: 'webmail-templates'` and `exportedAt` to `exportAll`; carry `isHTML`/`defaultRecipients`/`identityId` through import so they survive a round trip.
|
||||
|
||||
- [ ] **Templates not in cross-device settings sync (#825)** — `P3` — `missing` (blocked) — deferred: blocked on native settings sync (issue #1)
|
||||
- What WEB does: templates ride in the per-account settings blob with tombstone merge (`stores/template-store.ts:208-218`, `lib/template-utils.ts:163-284`).
|
||||
- What RN does: no settings sync exists at all (native issue #1 "Common settings / global settings" is open), so this is blocked on that feature.
|
||||
- Fix hint: when settings sync lands, reuse `mergeSyncedTemplates` (pure TS).
|
||||
|
||||
### mailto / share intents
|
||||
|
||||
- [ ] **No `mailto:` handling / share-to-app intent** — `P2` — `missing` — composer side (prefill*, prefillAttachments upload) done in 5b72c4d; scheme/intent filters + Linking → Compose belong to area 08
|
||||
- What WEB does: PWA `protocol_handlers` for mailto (`app/manifest.ts:118`), `parseMailtoUrl` (`lib/validation.ts:133-167`), in-app composer open through the unsaved-draft guard (`components/mail/mail-app.tsx:1110`, changelog 1.9.0).
|
||||
- What RN does: `app.config.js:29` registers only the custom `bulwarkmobile` scheme; no `intentFilters` (Android `mailto`/`SEND`) or iOS `LSApplicationQueriesSchemes`/`CFBundleURLTypes` for `mailto`; no `Linking.getInitialURL`/`addEventListener('url')` handling that routes to `Compose` (grep over `App.tsx`/`src/navigation` shows none). Tapping a mailto link in another app cannot open Bulwark; sharing a file/text to the app is impossible.
|
||||
- Fix hint: add `scheme: ['bulwarkmobile', 'mailto']` + Android `intentFilters` for `mailto` and `android.intent.action.SEND`, parse with a port of `parseMailtoUrl`, and navigate to `Compose` with `prefillTo`/subject/body (add those params); for SEND intents seed `attachments` from the shared `content://` URI (upload path already supports it, `src/api/blob.ts:17-22`).
|
||||
|
||||
### Security / crypto
|
||||
|
||||
- [ ] **S/MIME sign/encrypt on send is a stub** — `P3` — `missing` — deferred: no raw-MIME send path in RN; certificates are stored only
|
||||
- What WEB does: crypto plugins hook `onComposeSend` (`email-composer.tsx:2180-2216`) and the store has `sendRawEmail` (`stores/email-store.ts:1724`).
|
||||
- What RN does: `src/components/settings/SmimeSettings.tsx` lists imported certificates ("Your Certificates", "Recipient Certificates") but `ComposeScreen.tsx` has no sign/encrypt control (`grep -i smime|sign|encrypt` → none) and `sendEmail` has no raw-MIME path.
|
||||
- Fix hint: out of scope for parity right now; document as a known gap in the settings pane ("certificates are stored but not yet used for sending").
|
||||
|
||||
### Misc
|
||||
|
||||
- [x] **Auto-add reply recipients to trusted senders** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x)
|
||||
- What WEB does: after a reply, To/Cc are added to the trusted-senders list/address book (`email-composer.tsx:2283-2294`).
|
||||
- What RN does: `settings-store.ts:105-106` has `trustedSenders`, but `ComposeScreen.tsx` never touches it (`grep -i trusted` → none).
|
||||
- Fix hint: after a successful reply push `finalTo`/`finalCc` emails into `trustedSenders` (dedupe).
|
||||
|
||||
- [ ] **Offline send is not queued** — `P3` — `missing` (RN-only opportunity) — deferred: needs a new outbox op kind carrying the whole Email/set + submission payload; drafts now autosave to the server instead
|
||||
- What RN does: `outbox-store.ts:40-43` only knows `keywords|mailboxes|destroy` ops; `performSend` fails with a network error when offline (`ComposeScreen.tsx:975-979`) and the composed text is lost unless the user keeps the screen open. WEB has no offline outbox either, so this is not a parity item, but once drafts exist the natural mobile behaviour is "save draft locally, submit when online".
|
||||
- Fix hint: add a `send` op kind carrying the Email/set create + submission payload, flushed by `outbox-store.flush`.
|
||||
|
||||
- [x] **Permission / picker error strings are not localized** — fixed in 5b72c4d — `P3` — `rn-only-bug`
|
||||
- What RN does: `ComposeScreen.tsx:620-623`, `:696-699`, `:719` hard-code "Photo library permission is required…", "Could not load image"; `identityError` fallback text at `:1052` ("identity unavailable", "Loading...").
|
||||
- Fix hint: route through `t()` with keys in `locales/<lang>/common.json`.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Scheduled send via `EmailSubmission` envelope `HOLDFOR` with explicit `rcptTo` (bare addresses, names stripped) and `maxDelayedSend`/FUTURERELEASE capability checks: `src/api/email.ts:828-844`, `src/api/jmap-client.ts:513-531`, `ComposeScreen.tsx:796-821` — matches `lib/jmap/client.ts:592-612`, `:3245-3258`.
|
||||
- Undo-send delay setting (0/5/10/20/30 s) applied only when the server supports delayed send: `ComposeScreen.tsx:825-828`, `ComposingSettings.tsx:42-48`.
|
||||
- Send-time readback of the live editor DOM with timeout + loud abort (RN #9/#11): `RichTextEditor.tsx:143-152`, `ComposeScreen.tsx:890-902`; escape-free page script with a parse test.
|
||||
- `Name <addr>` recipients split into JMAP `name`/`email` (#672): `ComposeScreen.tsx:69-84`, `:944-947`.
|
||||
- Duplicate recipients within one field rejected on blur/submit: `ComposeScreen.tsx:466-499`; suggestion list hides already-added addresses `:371-392`.
|
||||
- Quote header values HTML-escaped (#482): `src/lib/compose-html.ts:88-100`.
|
||||
- Inline images: data-URL placeholder in the editor, `cid:` rewrite + `disposition:'inline'` parts on send, orphaned cids not attached: `compose-html.ts:143-161`, `ComposeScreen.tsx:910-927`; removing the chip strips the `<img>`.
|
||||
- Attachment reminder with word-boundary keyword match and configurable keyword list: `ComposeScreen.tsx:764-791`, `ComposingSettings.tsx:95-136`.
|
||||
- Send blocked while uploads are in flight or errored (web waits instead; equivalent outcome): `ComposeScreen.tsx:502-516`.
|
||||
- Body/attachment presence required to send; dirty-check close guard on the header X: `ComposeScreen.tsx:506-541`.
|
||||
- Auto-select reply identity from original To/Cc + setting: `ComposeScreen.tsx:431-437`, `settings-store.ts:131`; preselect identity matching the active account (a971d5a1 / "pre-select primary sender identity"): `:426-429`.
|
||||
- Identity picker bottom sheet (`src/components/IdentitySheet.tsx`), identity CRUD in settings with email immutable on edit (`IdentitySettings.tsx:263-277`).
|
||||
- Guarded UUID generator (a92c852c equivalent): `src/lib/uuid.ts:6-20`.
|
||||
- Pasted `<script>`/`<style>` stripped before send: `ComposeScreen.tsx:913` via `stripDangerousTags`; link insertion rejects non-http/mailto/tel schemes: `editor-html.ts:380-384`.
|
||||
- Plain-text-only send (text/plain part only) when `plainTextMode`: `ComposeScreen.tsx:951`, `src/api/email.ts:798-806`.
|
||||
- Blob upload tolerant of both Stalwart upload-response shapes: `src/api/blob.ts:38-61`.
|
||||
- Scheduled list filters `undoStatus==='pending'` and future `sendAt`, cancel via `undoStatus:'canceled'` keeping the Sent copy: `src/api/email.ts:902-988`.
|
||||
- Contact prefill from contact/group detail: `ContactDetailScreen.tsx:159`, `GroupDetailScreen.tsx:83`.
|
||||
|
||||
## N/A on mobile
|
||||
- Ctrl/Cmd+Enter send, Ctrl+Shift+Enter schedule, `t` template shortcut, Escape close (`email-composer.tsx:2428-2468`).
|
||||
- Drag-and-drop of files onto the composer and drag-to-reorder / drag-between-fields recipient chips (#593), chip drag preview.
|
||||
- Pro multi-account From dropdown namespacing (44cdd083), per-pane composer tabs, tab-title logic (`lib/subject-prefix.ts:129-144` buildComposeTabTitle), "Keep the fresh-compose tab title clear".
|
||||
- Composer sidebar / toolbar plugin slots and all `emailHooks.*` plugin transforms (no plugin runtime in RN).
|
||||
- `beforeunload` server autosave (browser lifecycle) — replaced by the `beforeRemove` recommendation above.
|
||||
- Browser `registerProtocolHandler`/PWA manifest mailto registration (replaced by OS intent filters above).
|
||||
- Signature "double-click to unlock" atom (Tiptap-specific); the RN contenteditable is always editable.
|
||||
- Quoted-HTML shadow-island typing detection (#654) — Tiptap/shadow-DOM specific.
|
||||
- Send through another logged-in account's client for DKIM (#461) — RN is single-active-account; identities always belong to the submitting account.
|
||||
- Route scheduled sends / identity on reschedule to a shared account (#874, a15af722) — RN does not compose on behalf of shared accounts.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Calendar & tasks
|
||||
|
||||
## Summary
|
||||
RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-viewer colours, client-side recurrence expansion that is a faithful port of WEB's, birthday calendar, tasks-only calendar hiding, push-driven refresh) but the write path is far behind WEB and has several data-corrupting bugs: the recurrence scope dialog is a stub (every choice edits/deletes the whole series, and editing an occurrence moves the whole series), all-day events grow by one day on every create/edit, events are created without a `timeZone`, and invitations are never actually sent because RN builds participants with the retired `sendTo` and never sets `organizerCalendarAddress`. There is no calendar management UI (create/rename/colour/delete/clear/share), no rights-based editability, no reminders/alerts firing, no trust assessment on invitations, no custom time zone, no Jalali, and the whole calendar UI is unlocalized even though RN has an i18n layer. Roughly half of the WEB changelog fixes since 1.4.5 apply to RN and are not there.
|
||||
|
||||
## Findings
|
||||
|
||||
### Recurrence
|
||||
|
||||
- [x] **Recurrence scope dialog is a stub: "This event" / "This and following" behave as "All events"** — `P1` — `missing` — fixed in 161baf1
|
||||
- What WEB does: `handleScopeSelect` implements all three scopes: "this" writes a `recurrenceOverrides/<recurrenceId>` patch (or the synthetic id on Stalwart >= 0.16.20), "this_and_future" truncates the master with `until` and creates a new series, "all" patches the master; delete does the mirror (exclude / truncate / destroy) and refetches the range (ref `components/calendar/calendar-app.tsx:842-998`, `lib/recurrence-overrides.ts:21-32`, changelog 1.9.0 "Edit a single recurring occurrence via a one-shot override patch").
|
||||
- What RN does: `handleScopeSelect` ignores the chosen scope with a TODO ("treat all scopes as 'all'") and either opens the edit modal on the occurrence or deletes `action.event.id`, which the store resolves to the master via `originalId` (ref `src/screens/CalendarScreen.tsx:347-363`, `src/stores/calendar-store.ts:306-329`). Choosing "This event" on delete destroys the entire series.
|
||||
- Fix hint: port WEB's `handleScopeSelect`/`truncateRecurrenceAtEvent`/`findMasterEvent` into CalendarScreen; add `buildRecurrenceOverridePatch` (WEB `lib/recurrence-overrides.ts`) and `RECURRENCE_OVERRIDE_IMMUTABLE_KEYS`; for "this" write `{ [`recurrenceOverrides/${recurrenceId}`]: override }` on `originalId`; for delete-this write `{ excluded: true }`; refresh after.
|
||||
|
||||
- [x] **Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts / earlier occurrences vanish)** — `P1` — `bug` — fixed in 161baf1
|
||||
- What WEB does: for scope "all" the updates go to the master but `start` from an occurrence is never applied to the master unless the user changed it; scope "this" goes into an override (ref `components/calendar/calendar-app.tsx:930-943`).
|
||||
- What RN does: `EventModal` seeds `start` from the occurrence (`getEventStartDate(event)`, `src/components/calendar/EventModal.tsx:197`) and `handleSave` always sends `start` + `duration` (`EventModal.tsx:262-281`); `updateEvent` resolves the occurrence to `originalId` and PATCHes the master (`src/stores/calendar-store.ts:306-318`). Any occurrence edit moves the whole series to that occurrence's date.
|
||||
- Fix hint: part of the scope work above; when scope is "all", drop `start` from the patch unless the user actually changed the date/time relative to the occurrence, or compute the delta and apply it to the master's `start`.
|
||||
|
||||
- [x] **Store's optimistic merge after an occurrence edit only updates one occurrence** — `P2` — `bug` — fixed in 161baf1
|
||||
- What WEB does: after any occurrence or master mutation with expanded occurrences in view it refetches the visible range (`stores/calendar-store.ts:186-206, 749-751`).
|
||||
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
|
||||
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
|
||||
|
||||
- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45
|
||||
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
|
||||
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
|
||||
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
|
||||
|
||||
- [ ] **Synthetic occurrence ids / server-side expansion (Stalwart >= 0.16.20) not used** — `P3` — `missing` — deferred: optional; client-side expansion is at parity and the scope dialog now writes overrides through the master, so the synthetic-id path buys nothing functionally yet
|
||||
- What WEB does: probes `CalendarEvent/set` with `h333333` once, then queries with `expandRecurrences: true`, hydrates occurrences from base events and writes occurrence patches through the synthetic id with a fallback to base-event overrides (ref `lib/jmap/client.ts:5648-5680, 5747-5751, 5828-5868`, `lib/recurrence-instances.ts:35, 73, 208-232`, `stores/calendar-store.ts:82-184`).
|
||||
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
|
||||
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
|
||||
|
||||
- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45
|
||||
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
|
||||
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
|
||||
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
|
||||
|
||||
- [x] **`this_and_future` / huge-span RRULE (#735) — RN not affected** — `P3` — `bugfix-parity` — verified, nothing to fix (caps already in place; "this and following" truncates with `until` and drops `count`)
|
||||
- What WEB does: memory note on #735 (Stalwart u32 overflow for spans > 136 years). WEB's editor caps `until` to a picked date and `count` to 999.
|
||||
- What RN does: same caps (`src/lib/recurrence.ts:227-229`, `RecurrenceEditor.tsx:139-144, 244-249`), no alarms with absolute triggers far in the future. Not affected; noted so it isn't re-investigated.
|
||||
|
||||
### Events: create / edit
|
||||
|
||||
- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45
|
||||
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
|
||||
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
|
||||
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
|
||||
|
||||
- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45
|
||||
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
|
||||
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
|
||||
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
|
||||
|
||||
- [x] **Changing the calendar in the edit modal is ignored (cannot move an event between calendars)** — `P2` — `rn-only-bug` — fixed in 161baf1 + 3fd5f10 (cross-account moves are not offered)
|
||||
- What WEB does: `calendarIds: { [calendarId]: true }` is part of the saved patch and the store remaps store ids to server ids (ref `components/calendar/event-modal.tsx:503`, `stores/calendar-store.ts:706-715`).
|
||||
- What RN does: `EventModal.handleSave` passes `calendarId` as the second argument, but `CalendarScreen.handleSave` only uses it on create (`src/screens/CalendarScreen.tsx:365-374`); the picker in edit mode is a no-op.
|
||||
- Fix hint: on update include `calendarIds: { [cal.originalId || calendarId]: true }` in the patch; for shared calendars the owning account differs, so either block cross-account moves or delete + recreate.
|
||||
|
||||
- [x] **Edit-modal calendar picker offers the Birthdays calendar, iCal-subscription calendars and read-only shared calendars (#762)** — `P2` — `bugfix-parity` — fixed in 3fd5f10
|
||||
- What WEB does: `canCreateEventsIn` excludes subscription calendars and calendars without `mayWriteAll`/`mayWriteOwn` (ref `lib/calendar-editability.ts:20-24`, changelog 1.9.0 #762).
|
||||
- What RN does: `EventModal` receives `eventCalendars`, which still contains the virtual birthday calendar (`src/screens/CalendarScreen.tsx:213-216, 227-231, 544-552`) and all shared calendars regardless of rights; picking Birthdays yields a server error, picking a subscription calendar lets the next feed sync delete the event (`src/stores/calendar-subscriptions-store.ts:58-74`).
|
||||
- Fix hint: filter with a RN `canCreateEventsIn(cal, isSubscriptionCalendar)` (subscriptions from `useCalendarSubscriptionsStore`), exclude `BIRTHDAY_CALENDAR_ID`.
|
||||
|
||||
- [x] **No rights-first editability (edit/delete offered on read-only and RSVP-only events)** — `P2` — `missing` — fixed in 3fd5f10
|
||||
- What WEB does: `getEventEditability` gates on calendar `myRights` first (`mayWriteAll`, `mayWriteOwn` + owner, `mayRSVP` + participant), subscription calendars are read-only, and the popover shows edit/delete vs. RSVP-only accordingly (ref `lib/calendar-editability.ts:47-76`, `components/calendar/event-detail-popover.tsx:161-171, 540-575`, changelog 1.9.0 "Rights-first event editability, including alias organizers").
|
||||
- What RN does: `EventDetailSheet` always renders Edit/Delete (`src/components/calendar/EventDetailSheet.tsx:328-351`); the only read-only guard is the birthday calendar (`src/screens/CalendarScreen.tsx:313-316`). The server rejects the write, but the user sees a generic error.
|
||||
- Fix hint: port `calendar-editability.ts`; pass `calendarsById`, the user's addresses (identities + aliases) and `isSubscriptionCalendar` into the sheet; hide Edit/Delete for `read-only`, show only RSVP for `rsvp-only`.
|
||||
|
||||
- [x] **`myRights.mayWrite` (non-existent JMAP property) used for writability everywhere** — `P2` — `rn-only-bug` — fixed in 3fd5f10
|
||||
- What WEB does: uses `mayWriteAll`/`mayWriteOwn`/`mayDelete` from RFC-style `CalendarRights` (ref `lib/calendar-editability.ts:20-24`, `components/calendar/calendar-sidebar-panel.tsx:328-334`).
|
||||
- What RN does: `CalendarRights.mayWrite` is documented as a "legacy short flag" (`src/api/types.ts:~507`) and is what `CalendarSidebarDrawer` (`:92-98`), `TasksSheet` (`:84-87`), `ICalImportSheet` (`:51-54`) and `CalendarInvitationBanner` (`:96`) test. Stalwart never sends it, so every calendar counts as writable and the "Subscribed" section can never populate.
|
||||
- Fix hint: replace with `!r || r.mayWriteAll || r.mayWriteOwn` and drop `mayWrite` from the type.
|
||||
|
||||
- [x] **No "send invitations" toggle in the editor** — `P3` — `missing` — fixed in 2abea97
|
||||
- What WEB does: `sendInvitations` checkbox (default on) decides `sendSchedulingMessages` (ref `components/calendar/event-modal.tsx:409, 594, 1333-1338`).
|
||||
- What RN does: always sends when participants exist (`src/stores/calendar-store.ts:22-24, 293-297`).
|
||||
- Fix hint: add a switch in the Participants section and thread it through `onSave`.
|
||||
|
||||
- [x] **Reminder picker is presets-only** — `P3` — `partial` — fixed in 2abea97 (custom amount + unit row; absolute / end-relative / non-display alerts survive a save)
|
||||
- What WEB does: alert rows with a number + unit (minutes/hours/days/weeks/at time), multiple rows, preserving exotic alerts (ref `components/calendar/event-modal.tsx:101-135, 358-386`, changelog 1.6.x #170).
|
||||
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
|
||||
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
|
||||
|
||||
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
|
||||
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
|
||||
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
|
||||
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
|
||||
|
||||
- [ ] **Quick natural-language event input** — `P3` — `missing` — deferred: low priority, needs a port of the webmail's quick-event parser and a text field above the month grid
|
||||
- What WEB does: `QuickEventInput` in the toolbar (ref `components/calendar/quick-event-input.tsx`, `calendar-toolbar.tsx`).
|
||||
- What RN does: none.
|
||||
- Fix hint: low priority; could reuse WEB's parser in a text field above the month grid.
|
||||
|
||||
### Invitations / scheduling
|
||||
|
||||
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
|
||||
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
|
||||
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
|
||||
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
|
||||
|
||||
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
|
||||
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
|
||||
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
|
||||
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
|
||||
|
||||
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
|
||||
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
|
||||
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
|
||||
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
|
||||
|
||||
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
|
||||
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
|
||||
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
|
||||
- Fix hint: drop the `..` check.
|
||||
|
||||
- [x] **Invitation banner: no trust assessment, no METHOD from Content-Type / raw ICS, no inline body-part detection** — `P2` — `partial` — fixed in 8206893 (auth results parsed from `email.headers`)
|
||||
- What WEB does: `getInvitationTrustAssessment` (DMARC/DKIM/SPF + sender/organizer mismatch -> trusted/caution/warning banner), `getInvitationMethod` reads `method=` from the attachment/body Content-Type, then `extractMethodFromRawIcs`, then infers; `findCalendarAttachment` also walks `textBody`/`htmlBody` sub-parts (ref `lib/calendar-invitation.ts:341-446`, changelog 1.6.x "RSVP with trust assessment").
|
||||
- What RN does: `inferInvitationMethod(parsed)` only (`src/components/email/CalendarInvitationBanner.tsx:62`; `extractMethodFromRawIcs` exists at `src/lib/calendar-invitation.ts:103-106` but is unused); `findCalendarAttachment` checks `email.attachments` only (`:139-148`); no trust UI. A spoofed invitation from an unauthenticated sender looks identical to a real one, and a REPLY/CANCEL whose ICS carries no participants is treated as `unknown` and offered "Add to calendar".
|
||||
- Fix hint: port `getInvitationTrustAssessment` (RN `Email` has `authenticationResults`?; if not, add to the fetched properties) and show a coloured banner + reason; read `attachment.type` params and fall back to downloading the blob text for `METHOD:`.
|
||||
|
||||
- [x] **Invitation banner imports into the first writable calendar, not the default calendar, and parses against the primary account** — `P3` — `partial` — fixed in 3fd5f10 + 8206893
|
||||
- What WEB does: imports into the default calendar of the account that owns the email; shared-folder emails are parsed against the folder owner (#867) and multi-account lookups are routed by source account (#847) (changelog 1.9.0).
|
||||
- What RN does: `calendars.find(cal => !cal.myRights || cal.myRights.mayWrite !== false)` (`src/components/email/CalendarInvitationBanner.tsx:96`) and `parseCalendarBlob(attachment.blobId)` on `jmapClient.accountId` (`:59`, `src/api/calendar.ts:355-372`). RN shows only the active account's mailboxes, so #847/#867 are largely N/A; the "first writable" pick is wrong when a subscription calendar sorts first.
|
||||
- Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`.
|
||||
|
||||
- [x] **Organizer not identified in the participant list; no "invited by", status counts or cancelled/tentative rendering** — `P3` — `partial` — fixed in 5dc4070 + 0f80e98 (organizer label, cancelled strike-through in sheet/card/month chips, tentative badge on cards; week-view blocks are untouched)
|
||||
- What WEB does: `getParticipantList` marks the organizer (also via `organizerCalendarAddress`, #731), the popover shows "(organizer)", strikes through `status === 'cancelled'` and badges `tentative` (ref `lib/calendar-participants.ts:126-157`, `components/calendar/event-detail-popover.tsx:299-312, 445-449`, changelog 1.7.2 #572).
|
||||
- What RN does: participants listed with status dots only (`src/components/calendar/EventDetailSheet.tsx:298-325`); no cancelled/tentative styling in sheet, `EventCard` or views.
|
||||
- Fix hint: port `getParticipantList`/`getStatusCounts`; add `line-through` when `event.status === 'cancelled'` in `EventCard`, `WeekView`, `EventDetailSheet`.
|
||||
|
||||
### Calendars
|
||||
|
||||
- [x] **No calendar management UI: create, rename, colour, description, delete, clear events, copy CalDAV URL** — `P2` — `missing` — fixed in dae5700 (no CalDAV URL copy: RN has no DAV base URL)
|
||||
- What WEB does: sidebar context menu + settings section with create (with kind picker), edit name/colour/description, clear all events (unlink-aware), delete, share, copy URL (ref `components/calendar/calendar-sidebar-panel.tsx:328-395`, `components/settings/calendar-management-settings.tsx`, `stores/calendar-store.ts:1028-1206`).
|
||||
- What RN does: `createCalendar` exists in the store but is only reachable through the subscription sheet (`src/stores/calendar-store.ts:378-382`, `src/stores/calendar-subscriptions-store.ts:82-84`); there is no `updateCalendar`/`removeCalendar`/`clearCalendarEvents` API or UI (`src/api/calendar.ts` has `deleteCalendar` at 417 but nothing calls it for own calendars). Long-press only offers set-default and shared-calendar recolour (`src/components/calendar/CalendarSidebarDrawer.tsx:208-211`).
|
||||
- Fix hint: add `Calendar/set` update + destroy (`onDestroyRemoveEvents`) to `src/api/calendar.ts`, expose in the store, and add "New calendar" + long-press "Rename / Colour / Delete / Clear" in the drawer. Deleting must be blocked for `isDefault` and shared calendars like WEB.
|
||||
|
||||
- [x] **Own-calendar colour cannot be changed (only shared calendars are recolourable)** — `P3` — `partial` — fixed in dae5700
|
||||
- What WEB does: colour picker writes `Calendar/set { color }` for own calendars and a local override for shared ones (ref `calendar-sidebar-panel.tsx:330, 366`, `lib/shared-calendar-colors.ts:33-40`).
|
||||
- What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`).
|
||||
- Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`.
|
||||
|
||||
- [x] **Shared-calendar colour key differs from WEB (not portable, but internally consistent)** — `P3` — `partial` — fixed in 0f80e98
|
||||
- What WEB does: key = `${localAccountId}|${accountId}|${originalId}` (ref `lib/shared-calendar-colors.ts:33-40`).
|
||||
- What RN does: key = `${accountId}|${id}` where `id` is already the namespaced `${accountId}:${raw}` (`src/lib/calendar-utils.ts:474-478`). Works, but the account id is duplicated in the key and would break if the namespacing format changes.
|
||||
- Fix hint: use `cal.originalId ?? cal.id` like WEB.
|
||||
|
||||
- [ ] **Calendar creation does not pin `supported-calendar-component-set` (#760)** — `P3` — `bugfix-parity` — deferred: the drawer now creates calendars over `Calendar/set` (dae5700); MKCALENDAR against Stalwart's `/dav/` endpoint needs a CalDAV client + calendar-home discovery that RN does not have
|
||||
- What WEB does: MKCALENDAR through the WebDAV proxy with `VEVENT`/`VTODO`/both, then finishes over JMAP; falls back to `Calendar/set` (ref `lib/jmap/client.ts:5366-5400, 5436-5485`, `components/calendar/calendar-kind-picker.tsx:16-18`, changelog 1.9.0 #760).
|
||||
- What RN does: plain `Calendar/set` (`src/api/calendar.ts:374-396`). Only matters once RN gains a create-calendar UI; RN has no WebDAV proxy, but it can talk to Stalwart's CalDAV endpoint directly (the same host serves `/dav/`).
|
||||
- Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id.
|
||||
|
||||
- [x] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` — fixed in 58e3ef6 (in-flight dedupe of fetchCalendars/fetchTasks; ensureRange awaits it)
|
||||
- What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907).
|
||||
- What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected.
|
||||
- Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch.
|
||||
|
||||
- [x] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` — fixed in 58e3ef6
|
||||
- What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird").
|
||||
- What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`).
|
||||
- Fix hint: port `isTaskLikeObject` and use it in both filters.
|
||||
|
||||
- [x] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` — fixed in 58e3ef6
|
||||
- What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access").
|
||||
- What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`).
|
||||
- Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect.
|
||||
|
||||
- [x] **Calendar sharing (JMAP `shareWith`) not available for calendars** — `P2` — `missing` — fixed in dae5700
|
||||
- What WEB does: `shareCalendar` via `Calendar/set shareWith/<principal>` with a principal picker and share indicators (ref `stores/calendar-store.ts:1078-1100`, `lib/jmap/client.ts:4921`, changelog 1.6.x "JMAP sharing for calendars and address books", #244, #257).
|
||||
- What RN does: sharing exists only for Files (`src/api/files.ts:229-241`, `src/components/files/ShareSheet.tsx`); nothing for calendars (also likely nothing for address books — that belongs to the contacts audit).
|
||||
- Fix hint: reuse `ShareSheet` + the principal picker with a `Calendar/set` `shareWith/{principalId}` patch; request `shareWith`/`myRights` in `Calendar/get` properties.
|
||||
|
||||
### Views / navigation / locale
|
||||
|
||||
- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa) and `formatReminder` (2abea97) localized too; deferred: ICalImportSheet, ICalSubscriptionSheet and RecurrenceEditor still hard-code English
|
||||
- What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal").
|
||||
- What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`).
|
||||
- Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`.
|
||||
|
||||
- [ ] **First day of week limited to Monday/Sunday; no Saturday, no Jalali (#490)** — `P3` — `missing` — Saturday (0|1|6) done in 0f80e98; deferred: Jalali month grid needs a port of `lib/jalali-utils.ts` + `useCalendarLocale`
|
||||
- What WEB does: `firstDayOfWeek` 0/1/6 with Saturday default for Persian and a full Jalali month grid / headers (ref `hooks/use-calendar-locale.ts:35-120`, `lib/jalali-utils.ts:161-169`, changelog 1.8.0 #490).
|
||||
- What RN does: `FirstDayOfWeek = 0 | 1` (`src/stores/settings-store.ts:76`), `MonthView`/`WeekView` typed `0 | 1` (`MonthView.tsx:34`, `WeekView.tsx:40`), no Jalali.
|
||||
- Fix hint: widen the type to `0|1|6`, add Saturday to `CalendarSettings`; Jalali is a larger port of `jalali-utils.ts` + a `useCalendarLocale` equivalent (only if the fa locale is added to RN).
|
||||
|
||||
- [x] **Week numbers are not ISO when the week starts on Monday; no week numbers in week view** — `P3` — `rn-only-bug` — fixed in 0f80e98 (month view; the week view header has no room for it)
|
||||
- What WEB does: `getISOWeek` when `weekStartsOn === 1`, else `getWeek(..., { weekStartsOn: 0 })` (ref `components/calendar/mini-calendar.tsx:74-79`).
|
||||
- What RN does: `getWeek(row[0], { weekStartsOn })` (`src/components/calendar/MonthView.tsx:137`) — without `firstWeekContainsDate: 4` this is not ISO numbering and can be off by one around New Year. WeekView has no week number.
|
||||
- Fix hint: use `getISOWeek` for Monday start; optionally show the number in the WeekView header row.
|
||||
|
||||
- [x] **"Show time in month view" setting is ignored (#666)** — `P3` — `bugfix-parity` — fixed in 0f80e98
|
||||
- What WEB does: on mobile, `showChips = !isMobile || showTimeInMonthView` renders event chips with times instead of dots (ref `components/calendar/calendar-month-view.tsx:49-52`, `event-card.tsx:80, 178`, changelog 1.8.0 #666).
|
||||
- What RN does: setting exists (`src/stores/settings-store.ts:169`, `CalendarSettings.tsx:435-443`) but `MonthView` always draws up to 3 dots (`src/components/calendar/MonthView.tsx:83-86`). RN affected.
|
||||
- Fix hint: pass the setting into `MonthView` and render a compact chip (title + start time) per event when on.
|
||||
|
||||
- [x] **Hover-preview setting exposed on mobile** — `P3` — `rn-only-bug` — fixed in 0f80e98 (store key kept for settings-sync compatibility)
|
||||
- What WEB does: hover preview is a desktop pointer feature.
|
||||
- What RN does: `CalendarSettings` shows "Hover preview" with delay options and nothing consumes `calendarHoverPreview` (`src/components/settings/CalendarSettings.tsx:87-102`; no other reference in `src/`).
|
||||
- Fix hint: remove the setting row (keep the store key for settings-sync compatibility or drop it).
|
||||
|
||||
- [x] **No day view (falls back to agenda); tapping a month day does not open the day** — `P3` — `partial` — fixed in 0f80e98 ("Day" hidden from the mobile default-view options; a synced "day" shows as Agenda)
|
||||
- What WEB does: day view with time grid; on mobile, tapping a date in month view switches to day view and offers "back to month" (ref `components/calendar/calendar-app.tsx:461-470`, `calendar-day-view.tsx`).
|
||||
- What RN does: `calendarDefaultView === 'day'` maps to agenda (`src/screens/CalendarScreen.tsx:131-137`); month view shows a list below the grid instead. Acceptable UX, but the settings option "Day" is misleading.
|
||||
- Fix hint: either reuse `WeekView` with a single-day `weekDays` array as a day grid, or hide "Day" from the RN default-view options.
|
||||
|
||||
- [x] **Week view: event block time label ignores 12h setting; timed grid has no long-press-at-minute precision** — `P3` — `partial` — fixed in 0f80e98
|
||||
- What WEB does: `formatSnapTime(minutes, timeFormat)` (ref `lib/calendar-utils.ts:278`).
|
||||
- What RN does: `minutesToTimeLabel` always `HH:mm` (`src/components/calendar/WeekView.tsx:315-319`) while the gutter honours 12h (`:229-231`). Long-press creates at the whole hour (`:123-128`), fine for mobile.
|
||||
- Fix hint: use `timePattern(timeFormat)` with `format()`.
|
||||
|
||||
- [x] **EventCard shows the description under a location (MapPin) icon and never shows the location** — `P3` — `rn-only-bug` — fixed in 0f80e98
|
||||
- What WEB does: card shows time, location and calendar (ref `components/calendar/event-card.tsx`).
|
||||
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
|
||||
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
|
||||
|
||||
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
|
||||
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
|
||||
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
|
||||
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
|
||||
|
||||
- [ ] **Deep links to calendar/event** — `P3` — `missing` — deferred: no `linking` config exists yet (mail area plumbing); `Calendar` route still takes no params
|
||||
- What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar/<view>/<date>?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files").
|
||||
- What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); no linking config for calendar. Only matters once RN adds a `linking` config; the mail area may already cover the plumbing.
|
||||
- Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet.
|
||||
|
||||
### Data loading / sync
|
||||
|
||||
- [x] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` — fixed in 58e3ef6
|
||||
- What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`).
|
||||
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
|
||||
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
|
||||
|
||||
- [x] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial` — fixed in 5fe8e40
|
||||
- What WEB does: whitelists the JSCalendar properties it sends (drops server-computed `utcStart`/`utcEnd`/`isOrigin`/`created`/`updated`, rewrites participants to `calendarAddress`, normalises all-day duration/timeZone) and links UIDs that exist in another calendar by adding the target calendar to `calendarIds` (ref `stores/calendar-store.ts:822-958`, changelog 1.5.x #113).
|
||||
- What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures").
|
||||
- Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch.
|
||||
|
||||
- [x] **iCal subscriptions: no per-account scoping, no edit, no auto-refresh/interval, no rollback, `webcals://`, basic-auth URLs** — `P2` — `partial` — fixed in dae5700
|
||||
- What WEB does: subscriptions carry `accountId` and are skipped for other accounts (`stores/calendar-store.ts:1360-1363, 1468-1472`), editable name/colour/url/interval (`:1294-1329`), refreshed every 5 min per interval (`components/calendar/calendar-app.tsx:320-324`), rolled back when the first fetch fails (`:1274-1289`), `webcals?://` normalised (`:1240`), basic auth in the URL supported server-side (#275), stale events unlinked rather than deleted when also in another calendar (`:1401-1427`).
|
||||
- What RN does: `calendar-subscriptions-store` persists `{ id, name, url, color, calendarId }` globally (`src/stores/calendar-subscriptions-store.ts:16-24, 162-166`) — after switching accounts the list still shows and `syncSubscription` imports into a calendar id of the other account (or a colliding raw id); `syncAll` is never called (`:155-160`); no edit UI (`ICalSubscriptionSheet.tsx`); a failed first sync leaves the empty calendar + sub with `lastError` (`:82-116`); only `webcal://` is rewritten (`:38-40`); `fetch('https://user:pass@host')` throws in RN's WHATWG fetch so #275 URLs fail; stale events are deleted outright (`:58-74`).
|
||||
- Fix hint: add `accountId: jmapClient.accountId` to each sub and filter by it; call `syncAll` on calendar mount + `AppState` foreground with a per-sub interval; add an edit sheet; on first-sync failure delete the calendar; regex `/^webcals?:\/\//i`; parse credentials out of the URL into an `Authorization` header.
|
||||
|
||||
- [x] **Subscription feeds are fetched on-device (security note, not a gap)** — `P3` — `partial` — fixed in dae5700 (10 MB cap)
|
||||
- What WEB does: `/api/fetch-ical` server-side with an SSRF guard, DNS pinning and redirect validation (GHSA-24w9, changelog 1.9.2).
|
||||
- What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`).
|
||||
- Fix hint: optional size cap.
|
||||
|
||||
- [x] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` — fixed in 58e3ef6
|
||||
- What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view.
|
||||
- What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`).
|
||||
- Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`.
|
||||
|
||||
- [ ] **Push: FCM subscription excludes `CalendarEvent`/`Calendar` types** — `P3` — `partial` — deferred: local reminders (8e1d03c) are scheduled from foreground fetches only; adding the calendar types to the FCM subscription lives in `src/lib/push-notifications.ts` (push area) and would need a background refresh + reschedule hook
|
||||
- What WEB does: push subscription covers calendar types so the sidebar refreshes on external changes (FEATURES.md "JMAP push keeps everything in sync").
|
||||
- What RN does: foreground EventSource/polling dispatches to `useCalendarStore.handleStateChange` (`App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`) — at parity while the app is open; the background FCM subscription lists only `['Email','EmailDelivery','Mailbox']` (`src/lib/push-notifications.ts:104`), so calendar changes never wake the app. Acceptable unless local reminders are added.
|
||||
- Fix hint: leave as is unless background calendar refresh becomes needed.
|
||||
|
||||
### Alerts / notifications
|
||||
|
||||
- [x] **Reminders never fire on mobile (no alert scheduler, no local notifications)** — `P2` — `missing` — fixed in 8e1d03c (expo-notifications local notifications scheduled from the store for the next 7 days, calendar default alerts honoured, cancelled events / completed tasks / acknowledged alerts skipped)
|
||||
- What WEB does: `useCalendarAlerts` polls every 60 s, proactively fetches the next 24 h, computes fire times from `alerts` (offset relative to start/end, absolute triggers, calendar default alerts via `useDefaultAlerts`), dedupes through a persisted acknowledged store, plays a sound and toasts; task due alerts too; cancelled events muted (#572) (ref `lib/calendar-alerts.ts:39-201`, `hooks/use-calendar-alerts.ts:21-158`, `stores/calendar-notification-store.ts`).
|
||||
- What RN does: `src/lib/calendar-alerts.ts` only converts offsets <-> reminder presets (1-103); the `calendarNotificationsEnabled`/`calendarNotificationSound` settings are shown in `NotificationSettings.tsx:52-53, 235-240` but nothing reads them; `package.json` has no `expo-notifications`. Users who set "15 minutes before" get nothing.
|
||||
- Fix hint: add `expo-notifications`, port `computeFireTime`/`getPendingAlerts`, and schedule local notifications for the next N days on every `fetchEvents` (cancel + reschedule by event id); honour `useDefaultAlerts` via `Calendar.defaultAlertsWithTime/WithoutTime` (add those to `Calendar/get` properties).
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **Task sheet supports only title + due date; no edit, description, priority, due time, progress states, alerts, filters, "show on calendar"** — `P2` — `partial` — fixed in 09c24fa (edit, description, priority, due date + time, calendar move, all/pending/completed/overdue filters, overdue-first sort, `needs-action` <-> `completed` with `progressUpdated`, due tasks overlaid on the grid; task alerts still come only from the server data)
|
||||
- What WEB does: task modal with description, due date + optional time, priority (none/high/medium/low -> 1-9), progress, calendar, alert; list filters all/pending/completed/overdue, overdue-first sort, `showTasksOnCalendar` overlays due tasks on the grid; completion toggles `needs-action` <-> `completed` with `progressUpdated` (ref `components/calendar/task-modal.tsx:55-140`, `task-list-view.tsx:80-110`, `stores/task-store.ts:366, 442-452`).
|
||||
- What RN does: `TasksSheet` creates `{ title, progress, due }` only, no tap-to-edit, delete + toggle only (`src/components/calendar/TasksSheet.tsx:117-135, 210-235`); un-completing sets `in-process` (`src/stores/calendar-store.ts:418-426`); `showTasksOnCalendar` (`settings-store.ts:174`) is unused; tasks are never drawn in month/week/agenda.
|
||||
- Fix hint: add a task edit sheet mirroring `task-modal.tsx`, filter chips, and render due tasks as all-day chips when `showTasksOnCalendar`.
|
||||
|
||||
### Settings
|
||||
|
||||
- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; deferred: birthday-calendar colour setting
|
||||
- What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`).
|
||||
- What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`).
|
||||
- Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Client-side recurrence expansion: `src/lib/recurrence-expansion.ts` is a faithful port of `lib/recurrence-expansion.ts` (byX filtering, bySetPosition, RDATE overrides, fast-forward, per-occurrence `utcStart`/`utcEnd` #116); only the server-instance guard differs.
|
||||
- Stalwart singular `recurrenceRule`/`excludedRecurrenceRule` normalisation on read and write (#13, JSCalendar 2.0) — `src/api/calendar.ts:64-126`.
|
||||
- Singular `inCalendar` OR-filter, `timeZone` on query/get, batched `CalendarEvent/get` (#141) — `src/api/calendar.ts:30-38, 210-231`.
|
||||
- Shared calendars via other session accounts, namespaced ids (`${accountId}:${id}`), `originalId`, event remapping, per-account query routing, best-effort failure isolation — `src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:35-58, 170-204`.
|
||||
- Per-viewer colours for shared calendars with auto-assigned unused palette colour (#345) — `src/screens/CalendarScreen.tsx:181-211`, `src/lib/calendar-utils.ts:465-511`.
|
||||
- Tasks-only calendars hidden from event UI and pickers (#28 / 9c084d9d) — `CalendarScreen.tsx:227-242`.
|
||||
- Set default calendar via `onSuccessSetIsDefault` — `src/api/calendar.ts:403-413`.
|
||||
- Malformed `utcStart`/`start` fallback (#316) — `src/lib/calendar-utils.ts:35-56`; undefined alert trigger guard (#143) — `src/lib/calendar-alerts.ts:57-60`.
|
||||
- Multi-day all-day bars aligned across the week, timed full-day promotion, cluster-based overlap layout — `src/lib/calendar-utils.ts:240-420`, `WeekView.tsx:69-101`.
|
||||
- Start move keeps duration (`EventModal.tsx:244-248`), Save disabled until title, long-press slot/day creates at the tapped time (7e9aeaf0 equivalent).
|
||||
- Server-sent iMIP via `sendSchedulingMessages` only (no client-side duplicate emails); import uses no scheduling (#411) — `src/api/calendar.ts:233-297`.
|
||||
- RSVP via RFC 6901-escaped `participants/<id>/participationStatus` — `src/api/calendar.ts:331-352`.
|
||||
- Birthday calendar generation incl. Feb 29 clamp and age — identical to WEB (`diff` clean apart from the extra WEB null fields).
|
||||
- iCal import with preview, select all/none, 5 MB cap, UID dedupe — `ICalImportSheet.tsx`.
|
||||
- Push StateChange refresh for `Calendar`/`CalendarEvent` on primary + shared accounts — `App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`.
|
||||
- Calendar tab hidden when the account lacks the JMAP calendars capability — `App.tsx:95, 160`, `src/lib/capabilities.ts:25`.
|
||||
- Week start setting, week numbers in month view, 12h/24h time format threaded through views.
|
||||
|
||||
## N/A on mobile
|
||||
- Mouse drag to move/resize, click-drag create, 15-minute snapping, top-edge resize, hover preview (`hooks/use-time-grid-interactions.ts`) — long-press create is the mobile equivalent.
|
||||
- Keyboard shortcuts (t/m/w/d/a/k/n, arrows), right-click context menus (long-press could carry the same actions, see "Duplicate/export" finding).
|
||||
- Pro multi-account aggregation (`hooks/use-pro-multi-account-calendars.ts`, `localAccountId::` namespacing, `reconcileSelectedIds`) — RN is single-active-account.
|
||||
- `/api/fetch-ical` server-side SSRF guard / DNS pinning (GHSA-24w9) — no server component in RN.
|
||||
- `webcal:` / `mailto:` protocol-handler registration and import-or-subscribe prompt for detected webcal links.
|
||||
- Agenda plugin sidecar, plugin event-action slot (Jitsi), Pro tab deep-link URL writing.
|
||||
- Refresh-gesture interception (F5/Ctrl-R) — RN has pull-to-refresh.
|
||||
@@ -0,0 +1,320 @@
|
||||
# Contacts & address books
|
||||
|
||||
## Summary
|
||||
RN covers the visible surface reasonably well (list with alphabetical index, detail screen with every RFC 9553 field block, a full edit form, groups with member management, address-book CRUD, vCard import/export, trusted-senders book, contact activity). The gaps are mostly below the UI: the RN JMAP layer (`src/api/contacts.ts`) ignores `notCreated/notUpdated/notDestroyed`, never assigns a UID (#644), does not re-fetch the created card (so a freshly saved contact shows as a blank row), does not paginate `ContactCard/query`, and has no first-touch gate (#907). The RN vCard parser is a stale fork of the web one: it predates #224 (dates are imported as raw strings, which Stalwart drops), lacks every X- vendor extension, Apple grouped labels, and org-only cards (#701). Composer autocomplete is contacts-only (no groups, no recent recipients, no directory), there is no "add sender to contacts" entry point from the viewer, no way to create a group, no shared/group-account address books, and the trusted-senders settings screen cannot show or remove entries that live in the synced book.
|
||||
|
||||
## Findings
|
||||
|
||||
### JMAP / store layer
|
||||
|
||||
- [x] **Newly created contact appears as a blank row until refresh** — `P2` — `bug` — fixed in 580e010
|
||||
- What WEB does: `client.createContact` reads the server-assigned id from `created` and then re-fetches the full card with `ContactCard/get` before returning it (`lib/jmap/client.ts:5128-5133`), so the store appends a complete card.
|
||||
- What RN does: `createContact` returns `res.methodResponses[0][1].created['new-contact']` verbatim (`src/api/contacts.ts:73`) which per JMAP only carries server-set properties (id, created, updated…); the store appends that stub (`src/stores/contacts-store.ts:170-174`). `ContactFormScreen.handleSave` just `goBack()`s (`src/screens/ContactFormScreen.tsx:631-633`) so the list shows an "Unnamed" row with no email until a push/pull refresh; `doDuplicate` even navigates to that stub (`src/screens/ContactDetailScreen.tsx:231-232`).
|
||||
- Fix hint: in `api/contacts.ts` merge `{ ...contact, addressBookIds, ...created }` or issue a follow-up `ContactCard/get` for the created id (mirror WEB) before returning.
|
||||
|
||||
- [x] **`ContactCard/set` errors are swallowed or crash with a TypeError** — `P2` — `bug` — fixed in 580e010
|
||||
- What WEB does: every set call checks `notCreated` / `notUpdated` / `notDestroyed` and throws the server `description` (`lib/jmap/client.ts:5122-5126, 5155-5160, 5180-5184`); the method-level `error` response is also handled.
|
||||
- What RN does: `createContact` indexes `.created['new-contact']` without checking `created`/`notCreated` (`src/api/contacts.ts:73`) so a rejected card surfaces as "Cannot read property 'new-contact' of undefined"; `updateContact` (`:76-85`) and `deleteContacts` (`:87-93`) never look at `notUpdated`/`notDestroyed`, so the store applies the optimistic change (`src/stores/contacts-store.ts:176-195`) although the server refused it (e.g. read-only shared book, invalid PartialDate).
|
||||
- Fix hint: route all three through `methodResult()` (already defined at `src/api/contacts.ts:7-15`) and throw on `notCreated/notUpdated/notDestroyed[id].description`; `bulkDelete` should only drop the ids that were actually destroyed.
|
||||
|
||||
- [x] **No UID assigned on contact creation (#644)** — `P2` — `bugfix-parity` — fixed in 580e010
|
||||
- What WEB does: sends `uid: contactData.uid || 'urn:uuid:<uuid>'` on every create because Stalwart stores the card without one otherwise (`lib/jmap/client.ts:5113-5116`, changelog 1.6.x "Assign a UID to contact cards on creation").
|
||||
- What RN does: `createContact` forwards the card as-is (`src/api/contacts.ts:65-70`); form, import, duplicate and trusted-sender creation all go through it. Group membership then falls back to the raw id (`src/screens/GroupDetailScreen.tsx:90`), and exported vCards/CardDAV clients get UID-less cards.
|
||||
- Fix hint: add `uid: contact.uid || \`urn:uuid:${generateUUID()}\`` in `api/contacts.ts createContact` (RN already has `src/lib/uuid.ts`).
|
||||
|
||||
- [x] **Contact list silently truncated at the query limit (no pagination)** — `P2` — `bug` — fixed in 580e010
|
||||
- What WEB does: `fetchPaginatedContacts` walks `ContactCard/query` with `position`/`limit = maxObjectsInGet` until `total` is reached, then batches `ContactCard/get` (`lib/jmap/client.ts:~4985-5022`).
|
||||
- What RN does: a single `ContactCard/query` with `limit: 1000` (`src/api/contacts.ts:26-40`); the server may clamp the limit lower and anything beyond is never fetched. `getContacts` batches the get correctly (`:42-57`).
|
||||
- Fix hint: loop on `position` until `ids.length < limit || total reached` in `queryContacts`, mirroring WEB.
|
||||
|
||||
- [x] **No first-touch gate: concurrent AddressBook/get + ContactCard/query on cold start (#907)** — `P2` — `bugfix-parity` — fixed in 26a34d5
|
||||
- Store-side: `fetchAddressBooks`/`fetchContacts` are single-flight and contacts wait for an in-flight AddressBook/get; the request-level `FirstTouchGate` in `src/api/jmap-client.ts` was added by the client agent.
|
||||
- What WEB does: `FirstTouchGate` serialises the first request per `(collection, account)` so Stalwart's lazy default-collection creation cannot race across cluster nodes (`lib/jmap/first-touch-gate.ts:1-101`, wired at `lib/jmap/client.ts:678,1129`; changelog 1.9.x "Gate first-touch calendar and contacts requests to stop duplicate default calendars (#907)").
|
||||
- What RN does: `ContactsScreen` fires `fetchAddressBooks()` and `fetchContacts()` in the same effect (`src/screens/ContactsScreen.tsx:120-123`) and `restoreSession` fans out `fetchContacts` + `fetchCalendars` (`src/stores/auth-store.ts:91-93`) with no gate in `src/api/jmap-client.ts` (grep for gate/firstTouch: none). Against a multi-node Stalwart this can mint a duplicate default address book / calendar.
|
||||
- Fix hint: port `first-touch-gate.ts` into `src/api/` and wrap `jmapClient.request` (key on `AddressBook/`, `ContactCard/`, `Calendar/`, `CalendarEvent/` + accountId); reset it on logout/account switch.
|
||||
|
||||
- [x] **Contacts account id: RN uses the mail primary account, not `primaryAccounts[contacts]`** — `P3` — `partial` — fixed in 580e010
|
||||
- What WEB does: `getContactsAccountId()` prefers `primaryAccounts["urn:ietf:params:jmap:contacts"]` (`lib/jmap/client.ts:4677-4680`).
|
||||
- What RN does: every call uses `jmapClient.accountId`, resolved from the mail/core primary (`src/api/contacts.ts:18,32,44,63`; `src/api/jmap-client.ts:388-399`).
|
||||
- Fix hint: add `getContactsAccountId()` to the RN client and use it in `api/contacts.ts`.
|
||||
|
||||
- [x] **Shared / group-account address books not listed or writable** — `P2` — `missing` — fixed in 26a34d5
|
||||
- Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via `originalId`/`accountId`.
|
||||
- What WEB does: `getAllAddressBooks`/`getAllContacts` iterate every contacts-capable or non-personal account, namespace ids as `accountId:bookId`, tag `isShared/accountName` (`lib/jmap/client.ts:4718-4733, 4756-4793, 5036-5070`); sidebar renders "Shared from X" sections (`components/contacts/contacts-sidebar.tsx:160-176, 562-603`); create/update/move de-namespace ids and pick the right account (`stores/contact-store.ts:473-527, 529-561, 926-978`); changelog 1.8.x "Require an explicit shared-account fallback for contacts and calendars".
|
||||
- What RN does: single-account only (`src/api/contacts.ts` throughout); `AddressBook` type has no `accountId/isShared` (`src/api/types.ts:369-377`).
|
||||
- Fix hint: iterate `session.accounts` where `accountCapabilities[contacts]` or `!isPersonal` (as WEB `getContactCapableAccountIds`), namespace ids, and render a "Shared from" section in `ContactsSidebarDrawer`; route writes to the owning account. The `myRights.mayWrite` checks already present in `AddressBookPickerSheet:96` and `ContactsSettings:204,209` will then become meaningful.
|
||||
|
||||
- [x] **Deleting a contact does not clean group member references locally** — `P3` — `partial` — fixed in 26a34d5
|
||||
- Drawer member counts resolve through `selectGroupMembers` in 358bdb9.
|
||||
- What WEB does: `cleanGroupMembers` strips the deleted card's id/uid/`urn:uuid:` variants from every group's `members` in state on delete/bulk delete (`stores/contact-store.ts:339-366, 571-578, 911-918`).
|
||||
- What RN does: `deleteContact`/`bulkDelete` just filter the list (`src/stores/contacts-store.ts:185-195`); group counts in the drawer use raw `Object.keys(g.members).length` (`src/components/contacts/ContactsSidebarDrawer.tsx:150`) so they keep counting deleted members until the next full refresh.
|
||||
- Fix hint: port `cleanGroupMembers`; resolve drawer counts through `selectGroupMembers` like WEB `memberCountByGroup` (`contacts-sidebar.tsx:250-270`).
|
||||
|
||||
- [x] **Deleting an address book leaves its contacts in the cache** — `P3` — `partial` — fixed in 26a34d5
|
||||
- What WEB does: `removeAddressBook` drops contacts filed in that book from state (`stores/contact-store.ts:1053-1056`).
|
||||
- What RN does: strips the book id but keeps the cards (`src/stores/contacts-store.ts:260-276`), so they show under "All"/"Uncategorized" until a refresh; the confirm text ("will be removed from this book") also implies they survive (`src/components/settings/ContactsSettings.tsx:282`).
|
||||
- Fix hint: filter out cards whose only book was the deleted one, or call `fetchContacts()` after the destroy.
|
||||
|
||||
- [x] **Persisting every contact card (incl. base64 photos) to AsyncStorage** — `P3` — `rn-only-bug` — fixed in 26a34d5
|
||||
- `media` is stripped from the persisted snapshot; picked photos are downscaled to 512px JPEG with `expo-image-manipulator` in 9800ad0.
|
||||
- What RN does: `partialize` persists `contacts` and `addressBooks` (`src/stores/contacts-store.ts:355-366`); cards carry inline `data:` photos (`src/screens/ContactFormScreen.tsx:690-691` stores the picked image as base64 with no downscale). Android AsyncStorage's default 6 MB cap will make the write fail for a few hundred photo contacts and the cache silently stops updating.
|
||||
- Fix hint: strip `media` from the persisted snapshot (re-hydrate photos from the server) and/or downscale photos on pick (WEB caps at 512px JPEG q0.85, `components/contacts/contact-form.tsx:96-126`).
|
||||
|
||||
### Address books
|
||||
|
||||
- [x] **Set default address book missing** — `P3` — `missing` — fixed in bc4c57e
|
||||
- `setDefaultAddressBook` (onSuccessSetIsDefault) and the `isDefault`-free update patch landed in 580e010; new contacts/imports prefer the default book via `getDefaultAddressBookId`.
|
||||
- What WEB does: context-menu "Set as default" uses `AddressBook/set onSuccessSetIsDefault` (`components/contacts/contacts-sidebar.tsx:612-663`, `lib/jmap/client.ts:4851-4869`, `stores/contact-store.ts:1017-1044`), and the create form preselects the default book (`components/contacts/contact-form.tsx:362-367`).
|
||||
- What RN does: no UI; `updateAddressBook` even forwards `isDefault` as a patch property (`src/api/contacts.ts:117,122`) which WEB notes is read-only and fails the whole update (`lib/jmap/client.ts:4819-4821`). New contacts/imports default to `addressBooks[0]` (`src/screens/ContactFormScreen.tsx:581,591-593`; `src/screens/ContactsScreen.tsx:201-204`; `src/components/settings/ContactsSettings.tsx:54`).
|
||||
- Fix hint: add `setDefaultAddressBook` (onSuccessSetIsDefault) to `api/contacts.ts` + a row action in `ContactsSettings`; drop `isDefault` from the update patch; prefer `books.find(b => b.isDefault)` as the default target.
|
||||
|
||||
- [x] **Default address book can be offered for deletion** — `P3` — `partial` — fixed in bc4c57e
|
||||
- What WEB does: hides delete for `isDefault`/shared books (`components/contacts/contacts-sidebar.tsx:615`).
|
||||
- What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error.
|
||||
- Fix hint: add `!book.isDefault` to the guard.
|
||||
|
||||
- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing`
|
||||
- deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists).
|
||||
- What WEB does: `shareAddressBook` via `shareWith/<principal>` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`).
|
||||
- What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257).
|
||||
- Fix hint: low priority on mobile; if added, reuse whatever calendar-sharing sheet the calendar agent proposes and request `properties` explicitly.
|
||||
|
||||
### Contact card / form
|
||||
|
||||
- [x] **Clearing a whole field group on edit never reaches the server** — `P2` — `rn-only-bug` — fixed in 9800ad0
|
||||
- What RN does: `formToPatch` only includes keys that are non-empty (`src/screens/ContactFormScreen.tsx:378-395`), and `updateContact` sends the patch as a JMAP `update` (`src/api/contacts.ts:82`). Removing the last phone/address/note/keyword/anniversary/online link of an existing contact therefore omits the key, which JMAP treats as "unchanged"; the store merge (`contacts-store.ts:178-182`) also keeps the old value, so the UI shows the removed items again.
|
||||
- Fix hint: in edit mode emit `null` for every collection the form owns that ended up empty (emails, phones, addresses, organizations, titles, anniversaries, onlineServices, personalInfo, notes, keywords, nicknames, speakToAs, calendarUri…) — the same approach WEB uses for `media: null` (`components/contacts/contact-form.tsx:555-558`).
|
||||
|
||||
- [x] **Saving the form drops non-photo media (logo, sound)** — `P3` — `rn-only-bug` — fixed in 9800ad0
|
||||
- What WEB does: copies every non-photo `media` entry through and keeps the original photo key (`components/contacts/contact-form.tsx:544-553`).
|
||||
- What RN does: rebuilds `media` with only `media.photo` (`src/screens/ContactFormScreen.tsx:359-366,395`), so an imported LOGO/SOUND is deleted on the first edit.
|
||||
- Fix hint: carry `existing.media` entries with `kind !== 'photo'` into the patch.
|
||||
|
||||
- [x] **Anniversary input accepts free text that the server rejects; year-only dates get a fake Jan 1** — `P2` — `rn-only-bug` — fixed in 9800ad0
|
||||
- `stringToPartialDate` / lossless `partialDateToString` live in `src/lib/contact-utils.ts` (580e010).
|
||||
- What WEB does: `<input type="date">` plus `stringToPartialDate` always yields a structured PartialDate (`components/contacts/contact-form.tsx:192-206, 525-528`); changelog 1.9.x #224 stresses that Stalwart drops non-PartialDate anniversaries.
|
||||
- What RN does: `stringToAnniversaryDate` returns the raw string for anything other than `YYYY-MM-DD` / `--MM-DD` (`src/screens/ContactFormScreen.tsx:116-128`), so "1990-05", "May 5" or a `YYYY-MM-DDT…` value is sent as a string → Stalwart rejects the set (and, via the previous finding, the failure is invisible). `partialDateToString` pads a year-only or year-month PartialDate with `01` (`:110`), so merely opening and saving a contact with `{year:1990}` rewrites it to 1990-01-01 — this feeds the birthday calendar with a wrong day.
|
||||
- Fix hint: reject/normalise input to PartialDate (support `YYYY`, `YYYY-MM`, `--MM-DD`, `---DD` like WEB `parseVcardDate`, `lib/vcard.ts:360-394`), and make `partialDateToString` lossless (`1990`, `1990-05`).
|
||||
|
||||
- [x] **Organization-kind cards (#701)** — `P3` — `partial` — fixed in 9800ad0
|
||||
- Detail subtitle no longer repeats the org name (1cef53a).
|
||||
- What WEB does: person/organization toggle, org name validates as the identity, `name.full` filled from org, `kind: 'org'` written, detail hides the duplicated org line (`components/contacts/contact-form.tsx:269-277, 443-449, 491-495, 560-563, 696-735`; `components/contacts/contact-detail.tsx:178-180`).
|
||||
- What RN does: no toggle/kind; validation requires given/surname/full or an email (`src/screens/ContactFormScreen.tsx:607-612`), so an org card needs its name typed into "Display name" and an org-only card without email cannot be created; detail subtitle repeats the org under the heading when they are equal (`src/screens/ContactDetailScreen.tsx:136`).
|
||||
- Fix hint: add a Person/Organization pill in the Identity section; when org: skip name components, set `name.full = org`, `kind: 'org'`, accept org name as identity; hide the equal subtitle.
|
||||
|
||||
- [x] **Photo data URIs from Stalwart not normalised (#307)** — `P2` — `bugfix-parity` — fixed in 580e010
|
||||
- What WEB does: `normalizeContactPhotoUri` rewrites `data:base64,…` / `data:;base64,…` to `data:image/jpeg;base64,…` in `getContactPhotoUri` and in the form (`stores/contact-store.ts:157-180`; `contact-form.tsx:376`).
|
||||
- What RN does: `getContactPhotoUri` returns `media.uri` raw (`src/lib/contact-utils.ts:71-77`), used by `ContactListRow:38`, `ContactDetailScreen:114` and the form (`ContactFormScreen.tsx:233-237`); RN `Image` cannot decode a media-type-less data URI so such photos render blank, and re-saving writes the malformed URI back.
|
||||
- Fix hint: port `normalizeContactPhotoUri` into `contact-utils.ts` and apply in `getContactPhotoUri` + `contactToForm`.
|
||||
|
||||
- [x] **Picked photos are stored full-size** — `P3` — `partial` — fixed in 9800ad0
|
||||
- What WEB does: downscales to 512px JPEG before embedding (`components/contacts/contact-form.tsx:96-126`) and rejects files >10 MB.
|
||||
- What RN does: `ImagePicker` with `quality: 0.8`, no dimension cap, base64 straight into the card (`src/screens/ContactFormScreen.tsx:680-695`).
|
||||
- Fix hint: use `expo-image-manipulator` to resize to ≤512px before reading base64.
|
||||
|
||||
- [x] **Duplicate contact keeps the source UID** — `P2` — `rn-only-bug` — fixed in 1cef53a
|
||||
- What WEB does: strips `id`, `uid`, `created`, `updated` before creating the copy (`components/contacts/contacts-app.tsx:448-451`).
|
||||
- What RN does: `doDuplicate` only strips `id/addressBookIds/created/updated` (`src/screens/ContactDetailScreen.tsx:216-222`), so the copy is created with the same `uid`; group membership by uid then matches both cards and CardDAV/vCard consumers see two cards with one UID.
|
||||
- Fix hint: also drop `uid` (and let `createContact` mint a fresh one once the #644 fix lands).
|
||||
|
||||
- [x] **Gender vocabulary differs from WEB and from the vCard exporter** — `P3` — `partial` — fixed in 9800ad0
|
||||
- What WEB does: `masculine | feminine | other | none | unknown` (vCard SEX mapping, `components/contacts/contact-form.tsx:1062-1069`), which `grammaticalGenderToVcardSex` understands (`lib/vcard.ts:34-48`).
|
||||
- What RN does: `masculine | feminine | common | neuter | animate | inanimate` (`src/screens/ContactFormScreen.tsx:437-445`); values other than masculine/feminine export as an empty GENDER and display differently across clients.
|
||||
- Fix hint: align option lists (or accept both on display) so the same card renders the same value in both apps.
|
||||
|
||||
- [x] **Online service `label` and multiple nicknames not editable** — `P3` — `partial` — fixed in 9800ad0
|
||||
- Detail shows every nickname (1cef53a).
|
||||
- What WEB does: online-service entries carry `label` (`contact-form.tsx:24-28, 517-523`); detail shows all nicknames (`contact-detail.tsx:194-196`).
|
||||
- What RN does: `OnlineDraft` has only `uri/service` (`src/screens/ContactFormScreen.tsx:42, 332-338`), and the form/detail read only the first nickname (`:168-170, 269-270`; `src/lib/contact-utils.ts:171-174`). Saving a card with several nicknames collapses them to one.
|
||||
- Fix hint: keep unknown nicknames when rebuilding `nicknames`, add a label field to the online row.
|
||||
|
||||
- [x] **Display name includes the middle name (differs from WEB sorting/grouping)** — `P3` — `partial` — fixed in 580e010
|
||||
- What WEB does: `given + surname` only, then `name.full`, nickname, org, email (`stores/contact-store.ts:125-150`).
|
||||
- What RN does: `given + middle + surname` (`src/lib/contact-utils.ts:13-14`). Harmless on its own but sort order, dedupe keys and `#672`-style name sanitising diverge between clients.
|
||||
- Fix hint: drop `middle` from the join or make it a display-only concern.
|
||||
|
||||
### List, search, sort
|
||||
|
||||
- [x] **Alphabetical index buckets every non-ASCII initial under "#"** — `P3` — `bug` — fixed in 358bdb9
|
||||
- What WEB does: `\p{L}` test + `Intl.Collator(locale, sensitivity base)` so Ä/É/Ł get their own sections (`components/contacts/contact-list.tsx:242-262`).
|
||||
- What RN does: `/[A-Z]/.test(letter)` (`src/screens/ContactsScreen.tsx:53-54`), so "Éric" and "Ölçer" land in "#".
|
||||
- Fix hint: use `/\p{L}/u` and `localeCompare` (Hermes supports Unicode property escapes).
|
||||
|
||||
- [x] **"Uncategorized" means "no address book" in RN but "no category" in WEB, and its count is hard-coded 0** — `P3` — `bug` — fixed in 358bdb9
|
||||
- `selectUncategorized` (no keywords) in 26a34d5; the drawer row moved under Tags as "No category" with a live count.
|
||||
- What WEB does: "No category" = contacts without keywords, with a live count (`components/contacts/contacts-sidebar.tsx:246-248, 526-541`; `contacts-app.tsx:283-285`).
|
||||
- What RN does: filter = contacts with no `addressBookIds` (`src/stores/contacts-store.ts:439-440`; `src/screens/ContactsScreen.tsx:146-149`), drawer row passes `count={0}` (`src/components/contacts/ContactsSidebarDrawer.tsx:128-136`) so the count never shows; on Stalwart every card has a book so the view is always empty.
|
||||
- Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count.
|
||||
|
||||
- [ ] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing`
|
||||
- deferred: optional; free-text search only.
|
||||
- What WEB does: filter drawer with tri-state chips (`components/contacts/contact-list.tsx:18-76, 154-228, 350-456`; changelog 1.5.x "Revamp contact detail view with filters").
|
||||
- What RN does: free-text search only (`src/lib/contact-utils.ts:216-241`).
|
||||
- Fix hint: optional; a filter sheet reusing the WEB predicate block would do.
|
||||
|
||||
- [x] **No select-all / bulk export in multi-select** — `P3` — `partial` — fixed in 358bdb9
|
||||
- What WEB does: select-all toggle, range select, bulk export, bulk add-to-group (`components/contacts/contact-list.tsx:283-305, 458-502`).
|
||||
- What RN does: long-press multi-select with delete / move / tag only (`src/screens/ContactsScreen.tsx:164-226, 282-313`).
|
||||
- Fix hint: add "Select all visible" and "Export selected" (reuse `contactsToVCard` + share flow from `ContactsSettings:56-78`).
|
||||
|
||||
- [x] **Rename category (keyword) missing** — `P3` — `missing` — fixed in 358bdb9
|
||||
- `renameKeyword` in the store (26a34d5); long-press a tag in the drawer to rename.
|
||||
- What WEB does: keyword context menu → rename, rewrites `keywords` on every affected card (`components/contacts/contacts-sidebar.tsx:680-698`; `stores/contact-store.ts:1087-1116`).
|
||||
- What RN does: tags can be assigned/removed per contact and in bulk (`TagAssignSheet`, `contacts-store.ts:213-235`) but not renamed.
|
||||
- Fix hint: long-press on a Tags row in `ContactsSidebarDrawer` → prompt → loop `updateContact` like WEB `renameKeyword`.
|
||||
|
||||
- [x] **Add-to-group from the contact itself missing** — `P3` — `partial` — fixed in 1cef53a
|
||||
- What WEB does: detail "More" menu and list context menu offer "Add to group" (creating a group if none) (`components/contacts/contacts-app.tsx:438-446`; `contact-detail.tsx:150-152`).
|
||||
- What RN does: membership is only editable from `GroupDetailScreen` (`src/screens/GroupDetailScreen.tsx:86-98`); the detail "More" sheet has Duplicate/Move/Export/Delete only (`src/screens/ContactDetailScreen.tsx:247-270`).
|
||||
- Fix hint: add a "Add to group" item that opens a group picker (list of `selectGroups`) and calls the same `members` patch as `GroupDetailScreen.addMembers`.
|
||||
|
||||
### Groups
|
||||
|
||||
- [x] **No way to create a contact group** — `P2` — `missing` — fixed in 9800ad0
|
||||
- "New group" row in the drawer and long-press on the header "+" (358bdb9); `createGroup`/`memberIds` route param.
|
||||
- What WEB does: "+" menu → "Create group" with name + member picker (`components/contacts/contacts-sidebar.tsx:299-305`; `contact-group-form.tsx`; `stores/contact-store.ts:734-760`).
|
||||
- What RN does: `ContactForm` supports `asGroup` (`src/screens/ContactFormScreen.tsx:554,627`) but the only caller passes an existing `contactId` for editing (`src/screens/GroupDetailScreen.tsx:138`; grep `asGroup` in `src/`: no other call site). The Contacts header "+" always opens a plain new-contact form (`src/screens/ContactsScreen.tsx:336-343`).
|
||||
- Fix hint: add "New group" to the header/drawer that navigates to `ContactForm { asGroup: true }` with a members picker (`ContactPickerSheet`), or a dedicated small screen that creates `{ kind:'group', name:{components:[{kind:'given', value}]}, members }`.
|
||||
|
||||
- [x] **Group edit reuses the full person form (name split into first/last, photo, emails…)** — `P3` — `partial` — fixed in 9800ad0
|
||||
- What WEB does: dedicated group form with name + searchable member checklist (`components/contacts/contact-group-form.tsx:80-196`).
|
||||
- What RN does: `GroupDetailScreen` → `ContactForm asGroup` shows Prefix/First/Middle/Last/Nickname, Email, Phone… (`src/screens/ContactFormScreen.tsx:747-813`); a group named "Sales Team" appears in "First name".
|
||||
- Fix hint: when `asGroup`, render a single "Group name" field bound to `given` and hide the person-only sections.
|
||||
|
||||
- [x] **Group compose: RN expands to individual recipients; no To/Cc/Bcc choice** — `P3` — `partial` — fixed in 1cef53a
|
||||
- Dedupe via `getGroupRecipients` (26a34d5); a single group chip is up to the composer agent.
|
||||
- What WEB does: hands the composer one expandable group chip (RFC 5322 group syntax) with a To/Cc/Bcc submenu (`components/contacts/contacts-app.tsx:561-587`; `contact-group-detail.tsx:69-87`; changelog 1.7.x "Contact groups as single expandable recipient chips").
|
||||
- What RN does: "Email all" pushes one recipient per member into `prefillTo` (`src/screens/GroupDetailScreen.tsx:72-84`). Functionally fine; dedupe by email is missing (two members sharing an address are both added).
|
||||
- Fix hint: dedupe case-insensitively; a group chip is optional unless the composer agent adds group chips.
|
||||
|
||||
### Composer autocomplete
|
||||
|
||||
- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing`
|
||||
- store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); wiring `ComposeScreen`/`ParticipantInput` belongs to the composer agent; directory principals not ported.
|
||||
- What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
|
||||
- What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`).
|
||||
- Fix hint: add `recentRecipients` (scan Sent `to/cc`, 300 msgs) to the contacts store, include groups as a pickable entry that expands to member emails on pick, and reuse `sanitizeDisplayName/splitMailbox`-style cleanup for names that contain a mailbox (#672).
|
||||
|
||||
### Viewer integration
|
||||
|
||||
- [ ] **"Add sender to contacts" from the message view missing** — `P2` — `missing`
|
||||
- contact-side done: `ContactForm { prefill }` seeds email + given/surname (9800ad0), `findContactByEmail` in the store (26a34d5); the sender sheet in `EmailThreadScreen` belongs to the viewer agent.
|
||||
- What WEB does: contact sidebar/popover on sender click with "Add to contacts" that creates a card with given/surname split from the display name (`components/email/email-viewer.tsx:5350-5366, 558-564`); also the mobile recipient popover (#306) and `/contacts/new?addEmail=` deep link (`components/contacts/contacts-app.tsx:182-198`).
|
||||
- What RN does: no contact lookup or "add" action in `EmailThreadScreen` (grep contact/ContactForm: only `SenderAvatar` at `:794`). Only `ContactForm`'s `route.params` support `contactId/addressBookId/asGroup` (`src/navigation/types.ts:24`) — no email/name prefill.
|
||||
- Fix hint: add `prefill?: { email; name }` to the `ContactForm` route, and a sender-tap sheet in `EmailThreadScreen` with "Add to contacts" / "Open contact" (lookup by email in `useContactsStore`).
|
||||
|
||||
- [ ] **Sender-name tap → contact popup missing** — `P3` — `missing`
|
||||
- deferred: viewer agent (same sheet as above; `findContactByEmail` + `ContactDetail { contactId }` are ready).
|
||||
- What WEB does: clicking the sender name opens the contact popup with details/actions (changelog 1.4.x "Show contact popup when clicking the sender name", `email-viewer.tsx:392-455`).
|
||||
- What RN does: none (see previous item).
|
||||
- Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`.
|
||||
|
||||
- [ ] **Contact deep links (`/contacts/<id>[/edit]`, `/contacts/new`)** — `P3` — `missing`
|
||||
- deferred: navigation/deep-link agent; contact routes already exist.
|
||||
- What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`).
|
||||
- What RN does: no `linking` config anywhere (grep `prefixes|LinkingOptions|getInitialURL` in `App.tsx`/`src`: none). Cross-reference the navigation/deep-link agent; contact-side requirement is just `ContactDetail { contactId }` / `ContactForm { contactId }` which already exist (`src/navigation/types.ts:23-24`).
|
||||
|
||||
### vCard import / export (`lib/vcard.ts` vs `src/lib/vcard.ts`)
|
||||
|
||||
- [x] **Imported dates are raw strings, not RFC 9553 PartialDate (#224) — birthdays vanish on Stalwart** — `P1` — `bugfix-parity` — fixed in e650cb2
|
||||
- What WEB does: `parseVcardDate` converts BDAY/ANNIVERSARY/DEATHDATE/X-ABDATE (basic + extended forms, reduced accuracy, `VALUE=text` skipped) into `{year, month, day}` and drops undecodable dates (`lib/vcard.ts:360-394`, `addAnniversary` in `buildContact` ~`:480-490`; changelog 1.9.x "Import vCard dates as RFC 9553 PartialDate and map common X- extensions (#224)").
|
||||
- What RN does: `card.anniversaries.a0 = { kind: 'birth', date: val }` with the raw string (`src/lib/vcard.ts` BDAY case ~`:687`, ANNIVERSARY ~`:701`, DEATHDATE ~`:709`), so every imported birthday is rejected by Stalwart (the whole `ContactCard/set` create fails with invalidProperties, and because of the error-handling finding above the failure is reported only as a generic "import failed for one contact" warning) — i.e. importing a vCard with a BDAY loses the entire contact, not just the date.
|
||||
- Fix hint: copy `parseVcardDate` + `addAnniversary` from WEB into RN `vcard.ts`; add a test with `BDAY:19850412`, `BDAY:--0412`, `BDAY;VALUE=text:circa 1800`.
|
||||
|
||||
- [x] **Vendor X- extensions not imported (X-ABDATE, X-ABRELATEDNAMES, X-SPOUSE/MANAGER/ASSISTANT, X-MAIDENNAME, X-GENDER, X-ANDROID-CUSTOM, X-AIM/X-SKYPE/… IM handles)** — `P2` — `bugfix-parity` — fixed in e650cb2
|
||||
- What WEB does: `X_ONLINE_SERVICES` map → onlineServices, `RELATION_LABELS`/`ANDROID_RELATION_TYPES` → relatedTo, `ANDROID_EVENT_KINDS` → anniversaries, X-MAIDENNAME → `surname2`, X-GENDER → grammaticalGender (`lib/vcard.ts:250-323, ~1009-1100, ~1113-1120`); export writes `X-MAIDENNAME` and `X-ABDATE` for `other` anniversaries (`~1173-1178, ~1280-1283`).
|
||||
- What RN does: `default: break` (`src/lib/vcard.ts:~858`); none of the maps exist (function list: no `X_ONLINE_SERVICES`, `RELATION_LABELS`, `ANDROID_*`). Export skips `surname2` and `other` anniversaries (`~:1025-1045`).
|
||||
- Fix hint: port the maps and the extension cases verbatim; both files otherwise share structure so a near-mechanical diff apply works.
|
||||
|
||||
- [x] **Apple grouped labels (`item1.TEL` + `item1.X-ABLABEL`) are lost** — `P2` — `bugfix-parity` — fixed in e650cb2
|
||||
- What WEB does: `splitGroupPrefix` keeps the group, `collapseProperties` folds a sibling `X-ABLABEL` into an `X-ABLABEL=` param, `decodeAppleLabel` strips `_$!<…>!$_` (`lib/vcard.ts:116-140, 413-434`); labels then land on emails/phones/addresses/online services.
|
||||
- What RN does: `stripGroupPrefix` discards the group (`src/lib/vcard.ts:120-131`) and properties are keyed by `keyPart` only (`:334-339`), so iOS/macOS exports lose every custom label and the `X-ABLABEL` lines are silently dropped (`:~854`).
|
||||
- Fix hint: port `splitGroupPrefix` + `collapseProperties` + `RawProperty` list handling in `parseVCard`.
|
||||
|
||||
- [x] **Org-only vCards are dropped on import and exported without FN (#701)** — `P2` — `bugfix-parity` — fixed in e650cb2
|
||||
- What WEB does: `hasOrg` counts as identity in `buildContact` (`lib/vcard.ts:~1123-1127`); `generateSingleVCard` falls back to the org name for the mandatory FN (`~:1158-1162`).
|
||||
- What RN does: `if (!hasName && !hasEmail && card.kind !== 'group') return null` (`src/lib/vcard.ts:~893-895`) so a company card with only ORG+TEL is skipped; export emits no `FN`/`N` when there is no personal name (`~:932-936`), producing an invalid vCard (FN is required by RFC 6350) that the RN importer itself would then reject.
|
||||
- Fix hint: apply the two WEB lines.
|
||||
|
||||
- [x] **Only one NICKNAME survives import** — `P3` — `bugfix-parity` — fixed in e650cb2
|
||||
- What WEB does: `card.nicknames[\`n${idx}\`]` (`lib/vcard.ts` NICKNAME case).
|
||||
- What RN does: `card.nicknames.n0 = { name: val }` overwrites (`src/lib/vcard.ts` NICKNAME case ~`:548`).
|
||||
- Fix hint: index like WEB.
|
||||
|
||||
- [x] **Import target defaults to the first book and cannot be changed from Settings** — `P3` — `partial` — fixed in bc4c57e
|
||||
- What WEB does: `client.createContact` without `addressBookIds` files into the `isDefault` book (`lib/jmap/client.ts:5095-5101`).
|
||||
- What RN does: `ContactsSettings` passes `books[0]` with no `onChangeTarget` (`src/components/settings/ContactsSettings.tsx:54, 269-275`); `ContactsScreen` allows changing the target but also starts at `addressBooks[0]` unless a book category is active (`src/screens/ContactsScreen.tsx:201-204, 476, 480-486`).
|
||||
- Fix hint: prefer `isDefault`; pass `onChangeTarget` in settings too.
|
||||
|
||||
- [x] **Import keeps the source UID (fine) but nothing mints one when absent** — covered by the #644 finding above; also `importContacts` swallows per-card failures with only a `console.warn` (`src/stores/contacts-store.ts:206-208`) so "Imported 0 contacts" is the only feedback. `P3` — add a failure count to the result sheet. — fixed in 26a34d5
|
||||
- The result sheet now shows how many cards failed to import.
|
||||
|
||||
### Trusted senders
|
||||
|
||||
- [x] **Trusted-senders settings show only the local list; synced book entries are invisible and cannot be removed** — `P2` — `partial` — fixed in 78114b6
|
||||
- What WEB does: modal shows the address-book entries when the feature is on, adds/removes through `addToTrustedSendersBook`/`removeFromTrustedSendersBook`, and settings show the synced count (`components/trusted-senders-modal.tsx:22-48`; `components/settings/content-senders-settings.tsx:25-40, 77-82`).
|
||||
- What RN does: `ContentSendersSettings` reads/writes only `settings-store.trustedSenders` (`src/components/settings/ContentSendersSettings.tsx:19-21, 100-144`); `removeFromTrustedSendersBook` exists in the store (`src/stores/contacts-store.ts:323-333`) but has no caller. A sender trusted from the viewer is written to both places (`src/components/EmailBodyView.tsx:644-651`), so deleting it in settings only removes the local copy and the book keeps trusting it (`EmailBodyView.tsx:513-516`).
|
||||
- Fix hint: in the modal, list `trustedSenders ∪ trustedSenderEmails`, mark book entries, and call `removeFromTrustedSendersBook` (+ local remove) on delete; show the union count.
|
||||
|
||||
- [x] **`trustedSendersAddressBook` setting is dead (`false`, no setter, no toggle) — book is consulted unconditionally** — `P3` — `partial` — fixed in 78114b6
|
||||
- Setting is now `boolean | null`, auto-`true` once the session advertises contacts, with a "Sync trusted senders to address book" toggle. Gating the passive load / write in `EmailBodyView` belongs to the viewer agent.
|
||||
- What WEB does: `null` until the account proves contacts support, then auto-`true` (`stores/auth-store.ts:380-385`, `stores/settings-store.ts:361,593`); toggle in settings (`content-senders-settings.tsx:77-82`); the book is only loaded/used when enabled (`mail-app.tsx:199-203`, `email-viewer.tsx:1705`).
|
||||
- What RN does: default `false`, no setter (`src/stores/settings-store.ts:106,239`); `EmailBodyView` loads the book regardless and ORs `trustedSenderEmails` into the trust check (`:480-485, 513-516`); the `addressBookEmails` branch (treats *every* contact as trusted) is gated on the dead flag (`:486-498`) so it never runs.
|
||||
- Fix hint: either drop the flag and the dead `addressBookEmails` branch, or expose a "Sync trusted senders to address book" toggle that gates the passive load and the `addToTrustedSendersBook` write.
|
||||
|
||||
- [x] **Race: trusting a sender while the passive book load is in flight silently fails** — `P2` — `rn-only-bug` — fixed in 26a34d5
|
||||
- What WEB does: single-flight promise shared by all callers (`stores/contact-store.ts:184-185, 1156-1199`), so `addToTrustedSendersBook` awaits the load and then has a `bookId`.
|
||||
- What RN does: `loadTrustedSendersBook` returns immediately when `trustedSendersLoading` is true (`src/stores/contacts-store.ts:279`); `addToTrustedSendersBook` then reads `bookId === null` and throws (`:312-317`), which the viewer swallows (`EmailBodyView.tsx:650`). The passive load is kicked off by the same screen a moment earlier (`:483-485`), so the very first "Trust sender" after app start is likely to hit this.
|
||||
- Fix hint: keep the in-flight promise in a module variable and return it; if the passive load found no book and `createIfMissing` is true, create it after the await.
|
||||
|
||||
- [x] **Duplicate "Trusted Senders" books are not consolidated and the pick is non-deterministic (#730)** — `P3` — `bugfix-parity` — fixed in 26a34d5
|
||||
- Deterministic pick (sorted by id); consolidation not ported.
|
||||
- What WEB does: sorts matches by id, uses the first, merges the rest and deletes them (`stores/contact-store.ts:1169-1187, 208-245`); `getAddressBooks({throwOnError})` prevents minting on a failed fetch.
|
||||
- What RN does: `books.find(b => b.name === …)` (`src/stores/contacts-store.ts:283`) — order-dependent; if the web already created a duplicate pair, RN may trust a different subset than web. RN only creates on explicit add (`:284-290`) and `fetchAddressBooks` throws on error, so RN itself does not mint duplicates.
|
||||
- Fix hint: sort by id before picking; optional consolidation.
|
||||
|
||||
- [x] **Trusted-sender card stores no display name; "Name <email>" input not parsed** — `P3` — `partial` — fixed in 26a34d5
|
||||
- What WEB does: parses `Name <email>` and stores `name.full` (`stores/contact-store.ts:1201-1225`).
|
||||
- What RN does: lower-cases the whole string and stores `emails` only (`src/stores/contacts-store.ts:308-321`); a `Name <email>` string (e.g. typed in settings) becomes an invalid address.
|
||||
- Fix hint: reuse the WEB regex.
|
||||
|
||||
- [ ] **Auto-trust reply recipients missing** — `P3` — `missing`
|
||||
- deferred: composer agent; `addToTrustedSendersBook` now accepts `Name <email>` and never races the passive load (26a34d5).
|
||||
- What WEB does: on reply/replyAll every To/Cc recipient is added to the trusted book (`components/email/email-composer.tsx:2284-2292`; changelog 1.4.x "Auto-add recipients to trusted senders when replying").
|
||||
- What RN does: `ComposeScreen` has no trusted-sender code (grep `trusted`: none).
|
||||
- Fix hint: after a successful send in reply mode call `addToTrustedSendersBook` (+ `addTrustedSender`) for each outgoing To/Cc.
|
||||
|
||||
### Activity / misc
|
||||
|
||||
- [x] **Contact activity: upcoming events come only from the already-loaded calendar cache** — `P3` — `partial` — fixed in 0e51465
|
||||
- What WEB does: queries the server for the next 365 days (`components/contacts/contact-activity.tsx:133-169`).
|
||||
- What RN does: filters `useCalendarStore().events` (`src/components/contacts/ContactActivity.tsx:90-91, 127-142`), i.e. only the range the calendar tab happened to load; on a fresh start it is empty.
|
||||
- Fix hint: call the RN calendar API for `[now, now+365d]` with a participant filter, falling back to the cache.
|
||||
|
||||
- [ ] **Print contact** — N/A on mobile (share vCard exists). Listed under N/A.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Address book list/create/rename/delete with `myRights` checks (`src/components/settings/ContactsSettings.tsx`, `AddressBookPickerSheet`); inline "New address book…" in the move sheet (WEB #415).
|
||||
- Move contacts between books (single from detail, bulk from list) via `addressBookIds` patch (`contacts-store.ts:237-241`) — RN equivalent of WEB drag-and-drop.
|
||||
- Group by first letter with sticky headers + settings toggle (`ContactsScreen.tsx:158-162`, `ContactsSettings.tsx:132-137`); sort by display name.
|
||||
- Search over name/emails/phones/orgs (RN also matches keywords).
|
||||
- Detail screen renders every WEB field block: emails/phones with contexts+features+labels, addresses (components, flat, timezone), org/units/titles/roles, anniversaries with age, gender/pronouns, languages, personalInfo, online services, calendar URIs, crypto keys, categories, relatedTo, notes, address book names, created/updated; quick actions call/SMS/mail/share; copy via long-press.
|
||||
- Contact form: prefix/given/middle/surname/suffix, nickname, display name (`name.full`), multiple emails/phones with context + phone feature pills, addresses as RFC 9553 components, multiple orgs (org/department/title/role), anniversaries with native date picker, online services, personalInfo w/ level, gender+pronouns, calendar URIs, categories (chips + suggestions from existing keywords), multiple notes, photo pick/remove, address book pick, dirty-state discard confirm, email regex validation, `media: {}` when photo removed (equivalent of WEB `media: null`).
|
||||
- `name.full` display fallback (#179), `getContactPrimaryEmail` with `pref`.
|
||||
- Group detail: member list resolved via id/uid/`urn:uuid:` (`contacts-store.ts:401-415`), add members via `ContactPickerSheet`, remove member, delete group, email all.
|
||||
- Tags (keywords) = WEB categories: drawer section with counts, filter by tag, bulk assign via `TagAssignSheet`, per-contact edit in form.
|
||||
- vCard import sheet: file picker, 5 MB cap, parse, duplicate detection by email (`detectDuplicates` identical), select all/none, target book, result count; export all (settings) and single (detail) with share sheet; multi-vCard files; QUOTED-PRINTABLE + charset decode (RN has a Hermes-safe `decodeBytes`), RFC 6868 params, KIND/MEMBER, PHOTO/LOGO/SOUND, RFC 6474/6715/8605/9554 properties.
|
||||
- Contact activity: recent emails via `from/to` OR filter (limit 5), opens the thread.
|
||||
- Trusted senders: same book name constant, passive load, add on "Trust sender" in the viewer, `isTrustedAddressBookSender` check.
|
||||
- Contacts tab hidden when the session lacks `urn:ietf:params:jmap:contacts` (`src/lib/capabilities.ts:29-31`, `App.tsx:170-179`) — WEB 1.8.x "Hide Contacts and Calendars when the account lacks the JMAP capability".
|
||||
- Push `StateChange` for `AddressBook`/`ContactCard` triggers refetch (`contacts-store.ts:152-168`, `App.tsx:390-396`); contacts cache persisted for instant render; selected category persisted.
|
||||
- Contact store reset on logout/account switch (`src/stores/auth-store.ts:58,76,113,179,379`) — the RN counterpart of the 583e20d9 namespacing fix (single-account store, so no id-form flip).
|
||||
|
||||
## N/A on mobile
|
||||
- Pro multi-account aggregation (`hooks/use-pro-multi-account-contacts.ts`, `fetchAllAccountsContacts`, `::` id namespacing) — Pro shell only; RN keeps one account active and resets the store on switch.
|
||||
- Local-storage (non-JMAP) contact fallback (`supportsSync === false`, `local-<uuid>` ids) — RN hides the tab when contacts are unsupported.
|
||||
- Drag-and-drop to address books / categories, right-click context menus, Ctrl/Shift range selection, resizable panes, keyboard shortcuts — replaced by long-press multi-select + sheets.
|
||||
- Print contact (`components/contacts/contact-print.ts`).
|
||||
- Plugin contact APIs and the `contact-cryptokeys` plugin slot.
|
||||
- `mailto:`/`tel:` handoff differences (RN uses `Linking`).
|
||||
- Native issue #34 (device contacts / calendar sync adapters) is a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Filters (Sieve), Vacation responder, Files (FileNode)
|
||||
|
||||
## Summary
|
||||
Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests as of ~1.7.2, so external/Nextcloud/opaque handling, `# Rule:` dedupe, brace-safe parsing and the store logic are at parity. It is missing the 1.7.3 "extended rules" change (multi-value conditions + attachment field + `mime` require); because both clients share one active script, a rule authored on WEB with those features either makes RN fall back to "opaque" mode or, worse, gets silently rewritten as `header :contains "undefined" ...` the next time RN saves anything. RN's FilterRuleModal also never re-seeds its state (always-mounted Modal), so editing a second rule shows and saves the first rule's fields. Vacation: RN has the basic JMAP VacationResponse flow but no HTML body, sends non-UTC `fromDate/toDate`, and has hard-coded English. Files: README's "UI stubs only" is stale - RN has real FileNode browsing (folder = blobId==null), create folder, single-file upload, rename, delete, download/share, RFC 9670 sharing with a principal picker and "shared with me" - but reads the non-existent `updated` property (dates always blank, sort-by-modified is a no-op), does not percent-decode WebDAV-created names, does not reset on account switch, and lacks preview, move/copy/paste/duplicate, favorites/recent, search, upload progress/limits and multi-file upload.
|
||||
|
||||
## Findings
|
||||
|
||||
### Filters (Sieve)
|
||||
|
||||
- [x] **RN generator corrupts WEB-authored attachment rules on any save** — fixed in 1c6aa68 — `P1` — `bug` (missing feature with data-loss side effect)
|
||||
- What WEB does: `attachment` field with `has_any`/`has_type` comparators emits `header :mime :anychild ...` and adds `mime` to `require` (`lib/sieve/generator.ts:48-67`, `:132`; types `lib/jmap/sieve-types.ts:16-30`; changelog 1.7.3 "Extended filter rules — attachment field and multi-value conditions").
|
||||
- What RN does: `src/lib/sieve/types.ts:21-26` has no `attachment` field. A single-value attachment rule from WEB passes RN's metadata validation (`src/lib/sieve/parser.ts:40-44` only requires strings) and is listed as an editable Bulwark rule. RN's generator has no attachment branch: `src/lib/sieve/generator.ts:36-62` falls through to `HEADER_MAP['attachment']` → `undefined`, emitting `header :contains "undefined" ""` and never adding `mime` to requires. Every RN save regenerates the whole script (`src/stores/filter-store.ts:102-118`: toggle, reorder, add, delete), so one toggle on the phone silently breaks the user's attachment rules server-side. WEB then re-reads that broken condition as a generic header rule.
|
||||
- Fix hint: port the WEB generator/parser/types diff (see next item) - `git diff` between `lib/sieve/generator.ts` and `src/lib/sieve/generator.ts` is ~60 lines. Until then, at minimum make `generateCondition` throw / keep `rawBlock` for unknown fields instead of emitting `"undefined"`.
|
||||
|
||||
- [x] **Multi-value conditions (string[] values) unsupported → WEB scripts become opaque on RN** — fixed in 1c6aa68 — `P1` — `missing`
|
||||
- What WEB does: `FilterCondition.value: string | string[]` (`lib/jmap/sieve-types.ts:40-49`); generator emits Sieve string-lists via `toValueList`/`formatStringArg` (`lib/sieve/generator.ts:15-32`, `57-90`); parser accepts list literals with `parseValueTail`/`classifyMatches` (`lib/sieve/parser.ts:344-400`, `455-484`) and validates array values (`parser.ts:37-44`); modal splits comma input on blur/save (`components/filters/filter-rule-modal.tsx:59-78`, `131-147`, `353-381`); summaries join with the locale's "or" (`components/settings/filter-settings.tsx:46-51`, `114-125`).
|
||||
- What RN does: `src/lib/sieve/types.ts:38` `value: string`; `src/lib/sieve/parser.ts:40-44` rejects array values so `parseScript` returns `OPAQUE` (`parser.ts:681`). Result: after a WEB user types "a, b" in one condition, RN shows the "edited outside the visual builder" banner with only raw editing and "Reset to visual builder" (which regenerates an empty script → all rules lost, `src/stores/filter-store.ts:188` + `saveFilters`). If a list value somehow reached the RN modal, `cond.value.trim()` at `src/components/filters/FilterRuleModal.tsx:159` would throw and `<Input value={condition.value}>` (`:271`) would receive an array.
|
||||
- Fix hint: apply the WEB diff to `types.ts`, `parser.ts` (`isValidCondition`, `parseValueTail`, `classifyMatches`, body regex), `generator.ts` (`toValueList`, `formatStringArg`, size scalar), then add comma-split/join helpers to `FilterRuleModal`, array-aware `summarizeRule`, and locale keys `value_placeholder_multi`, `attachment_type_placeholder`, `condition_fields.attachment`, `comparators.has_any/has_type` (present in WEB `locales/en/common.json`, absent in RN `locales/en/common.json` settings.filters).
|
||||
|
||||
- [x] **FilterRuleModal never re-initialises: editing rule B shows/saves rule A's fields** — fixed in 1c6aa68 — `P1` — `rn-only-bug`
|
||||
- What WEB does: `FilterRuleModal` is conditionally mounted (`components/settings/filter-settings.tsx:697-707`), so `useState(rule?.name)` etc. run fresh per open.
|
||||
- What RN does: `src/components/settings/FilterSettings.tsx:359-365` always renders `<FilterRuleModal visible={showRuleModal} rule={editingRule} .../>`; the modal seeds `name/matchType/conditions/actions/stopProcessing` only in `useState` initialisers (`src/components/filters/FilterRuleModal.tsx:74-82`) and has no reset effect. Opening "Add Rule" after editing a rule shows the previous rule's content; opening rule B after rule A shows A's fields but saves them under B's `id` (`:170`), overwriting B. Compare `SieveEditorSheet.tsx:33-39`, which does re-seed on `visible`.
|
||||
- Fix hint: either mount the modal conditionally (`{showRuleModal && <FilterRuleModal .../>}`) or add a `useEffect([visible, rule])` that resets all local state like `SieveEditorSheet` does.
|
||||
|
||||
- [x] **Vacation-active banner is not actionable** — fixed in 3ffcef2 — `P3` — `partial`
|
||||
- What WEB does: banner is a button that switches the settings tab to Vacation (`components/settings/filter-settings.tsx:483-507`, `vacation_configure` label).
|
||||
- What RN does: plain `View` with title/description only (`src/components/settings/FilterSettings.tsx:231-241`); no "Configure →" affordance.
|
||||
- Fix hint: accept an `onOpenVacation` prop from `SettingsScreen` and wrap the banner in a `Pressable`.
|
||||
|
||||
- [x] **Expanded ("IF / THEN") visual rule summary missing** — fixed in 3ffcef2 — `P3` — `partial`
|
||||
- What WEB does: `VisualRuleSummary` renders condition/action chips with IF/THEN labels and the match-type hint when `expandedFilterView` is on (`components/settings/filter-settings.tsx:89-156`, `612-616`); changelog 1.4.6 "Add expanded visual view for filter rules".
|
||||
- What RN does: the `filtersExpandedView` toggle exists (`src/components/settings/FilterSettings.tsx:350-355`) but only lifts the `numberOfLines` clamp on the same one-line summary (`:268`, `:292`); `if`/`then`/`match_all_conditions` locale keys are unused.
|
||||
- Fix hint: add a `VisualRuleSummary` component rendering rows of chips (all conditions, all actions) when `expandedView` is true.
|
||||
|
||||
- [ ] **Per-account (shared/group) filters not supported** — `P3` — `missing` (depends on whether RN gets managed shared-account settings at all) — deferred: RN has no managed/shared-account settings yet
|
||||
- What WEB does: `filter-store` tracks `availableAccounts`/`selectedAccountId` and every Sieve call takes an `accountId` (`stores/filter-store.ts:62-134`); `FilterSettings` scopes to `managedAccountId` and fetches that account's mailboxes for the move target list (`components/settings/filter-settings.tsx:188-232`, `248-255`); client `getSieveAccounts()` (`lib/jmap/client.ts:4465-4469`).
|
||||
- What RN does: `src/api/sieve.ts:20-23` always uses the primary Sieve account; store has no account selection (`src/stores/filter-store.ts:32`). No `managedAccount` concept exists in RN (`grep -ri managedAccount src` → none).
|
||||
- Fix hint: only if the Accounts area adds shared-account management: thread an optional `accountId` through `api/sieve.ts` and the store's `fetchFilters/saveFilters`.
|
||||
|
||||
- [x] **Sieve test files have mojibake in test names** — fixed in 1c6aa68 — `P3` — `rn-only-bug`
|
||||
- What WEB does: `lib/sieve/__tests__/*.test.ts` use "→" in `it(...)` names.
|
||||
- What RN does: `src/lib/sieve/__tests__/{parser,generator,external-rules}.test.ts` start with a UTF-8 BOM and contain "→" (double-encoded arrow) at e.g. `external-rules.test.ts:257`, `:343`, `generator.test.ts:369`, `parser.test.ts:104`. Cosmetic, but it shows the files were copied through a wrong encoding; re-copy from WEB when porting the 1.7.3 changes and add tests for list values / `:mime`.
|
||||
- Fix hint: rewrite the three files as UTF-8 without BOM from the WEB copies.
|
||||
|
||||
- [ ] **Raw Sieve editor lacks line-number gutter / Tab-inserts-spaces** — `P3` — `partial` (mostly N/A on mobile)
|
||||
- What WEB does: gutter with line numbers, Tab inserts two spaces (`components/filters/sieve-editor-modal.tsx:61-72`, `101-128`).
|
||||
- What RN does: shows "N lines" below the editor (`src/components/filters/SieveEditorSheet.tsx:107`). Acceptable on mobile; listed so nobody re-audits it.
|
||||
|
||||
### Vacation responder
|
||||
|
||||
- [x] **fromDate/toDate sent without timezone designator (not a JMAP UTCDate)** — fixed in b538a0e — `P2` — `rn-only-bug` — not verified live against stw-test; RFC 8621 UTCDate semantics implemented
|
||||
- What WEB does: `datetime-local` → `new Date(local).toISOString()` (`"...T10:00:00.000Z"`) (`components/settings/vacation-settings.tsx:196`, `:207`) and displays via `utcToLocalDatetime` (`:16-20`).
|
||||
- What RN does: `toJmapDate` produces `"YYYY-MM-DDTHH:MM:SS"` with no `Z`/offset (`src/components/settings/VacationSettings.tsx:10-19`) and `fromJmapDate` just slices the server's UTC string, showing UTC as if local (`:21-24`). RFC 8621 `VacationResponse.fromDate` is a `UTCDate`; Stalwart will either reject the set (`invalidProperties`) or interpret the wall time as UTC, shifting the schedule by the user's offset. Not verified live - verify against stw-test (`reference_stalwart_stw_test_container.md`) before fixing.
|
||||
- Fix hint: parse the typed local time with `new Date(y, m-1, d, hh, mm).toISOString()` and format for display with the device timezone; better, use a native date-time picker.
|
||||
|
||||
- [x] **HTML vacation body missing** — fixed in b538a0e — `P2` — `missing`
|
||||
- What WEB does: "Formatted message (HTML)" toggle + `RichTextEditor`, sanitised on save with a plain-text fallback derived from the HTML, HTML preview (`components/settings/vacation-settings.tsx:47-48`, `81-83`, `109-127`, `242-258`, `279-293`); store carries `htmlBody` (`stores/vacation-store.ts:10`); changelog 1.7.8 "Vacation: HTML body support".
|
||||
- What RN does: store/API already carry `htmlBody` (`src/api/vacation.ts:11`, `src/stores/vacation-store.ts:15`) but the screen never reads or writes it (`src/components/settings/VacationSettings.tsx:63-71` saves only `isEnabled/fromDate/toDate/subject/textBody`). Saving from RN leaves any WEB-set `htmlBody` untouched (partial update), so no data loss - just no way to see/edit/clear it.
|
||||
- Fix hint: reuse the composer's editor (RN rich editor) behind an "HTML" toggle; when off, send `htmlBody: null`; derive `textBody` from HTML when empty.
|
||||
|
||||
- [x] **No "unsaved changes" gating; Save always enabled** — fixed in b538a0e — `P3` — `partial`
|
||||
- What WEB does: `hasChanges` compares local vs. store and disables Save when nothing changed or the end is before the start (`components/settings/vacation-settings.tsx:94-102`, `317`).
|
||||
- What RN does: `canSave` only checks warnings (`src/components/settings/VacationSettings.tsx:61`).
|
||||
- Fix hint: add a `hasChanges` memo mirroring WEB.
|
||||
|
||||
- [x] **"Start date in the past" warning missing** — fixed in b538a0e — `P3` — `partial`
|
||||
- What WEB does: `warnings.start_in_past` (`components/settings/vacation-settings.tsx:75-79`).
|
||||
- What RN does: only end-before-start, format, empty-body (`src/components/settings/VacationSettings.tsx:56-59`).
|
||||
- Fix hint: add the check; RN `locales/en/common.json` already has `settings.vacation.warnings`.
|
||||
|
||||
- [x] **Vacation screen is hard-coded English despite locale keys existing** — fixed in b538a0e — `P3` — `rn-only-bug`
|
||||
- What WEB does: all strings via `settings.vacation.*`.
|
||||
- What RN does: `VacationSettings.tsx` never calls `useLocaleStore`; "Vacation Responder", "Date Range", "Saved", warnings etc. are literals (`:57-59`, `:72-74`, `:88`, `:101-158`). RN `locales/en/common.json` already contains `settings.vacation.{title,description,status,date_range,message,preview,save,saving,warnings}`.
|
||||
- Fix hint: wire `t('settings.vacation.…')` like `FilterSettings.tsx` does.
|
||||
|
||||
- [ ] **Vacation fetch/save on a shared account** — `P3` — `missing` (same dependency as the filters item) — deferred: same dependency (no managed-account concept in RN)
|
||||
- What WEB does: `fetchVacationResponse(client, managedAccountId)` (`components/settings/vacation-settings.tsx:52-56`, `121-128`).
|
||||
- What RN does: always `jmapClient.accountId` (`src/api/vacation.ts:27`, `:43`).
|
||||
|
||||
### Files (JMAP FileNode)
|
||||
|
||||
- [x] **Modification date never shown / sort-by-modified is a no-op (reads `updated`, server property is `modified`)** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.8.1 "Files: Show the modification date instead of the creation date (#700)", commit 348e032d)
|
||||
- What WEB does: requests and reads `modified` (`lib/jmap/client.ts:6478-6486` `FILE_NODE_PROPERTIES`; `lib/jmap/types.ts:891-895` explains that asking for the wrong name silently yields `undefined`; `stores/file-store.ts:177` `lastModified: node.modified || node.created`).
|
||||
- What RN does: `src/api/files.ts:18-21` requests `'updated'`; `src/api/types.ts:548` declares `updated?: string`; `FilesScreen.tsx:222-225` sorts on `a.updated` and `:527-529` renders `item.updated` → always undefined, so the list shows no date and "Sort: Modified" does nothing. `FilesSettings` preview promises a Modified column.
|
||||
- Fix hint: rename to `modified` in `FILE_NODE_PROPERTIES`, the `FileNode` type, `FilesScreen` sort/render; fall back to `created` like WEB.
|
||||
|
||||
- [x] **Percent-encoded names from WebDAV-created nodes shown raw** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.9.0 "Decode percent-encoded FileNode names from WebDAV-created nodes (#869)", commit 5109aa05)
|
||||
- What WEB does: `decodeFileNodeName` (`lib/jmap/filenode-name.ts:17-28`) applied at the client boundary in `getFileNodes`/`listAllFileNodes`/`listAllFileNodesAcrossAccounts` (`lib/jmap/client.ts:23-25`, `:6518`, `:6567`, `:6594`); conservative (needs a valid `%XX`, refuses decoded `/` or NUL).
|
||||
- What RN does: `getAllFileNodes`/`getAllFileNodesAcrossAccounts` (`src/api/files.ts:61-126`) return `node.name` verbatim; "Spares%20Catalog" shows as such.
|
||||
- Fix hint: port `filenode-name.ts` to `src/lib/` and map nodes through it in both list functions (keep the raw name only if a rename must round-trip the original - WEB doesn't).
|
||||
|
||||
- [x] **Files drive not reset on account switch** — fixed in c0e1d6b — `P2` — `bugfix-parity` (changelog 1.9.0 "Reset the account-scoped Files drive on every account switch", commit 5c06ae8b)
|
||||
- What WEB does: `initedAccountRef` tracks the account; on change it `clearClient()`s and re-bootstraps (`components/files/files-app.tsx:110-114`, `160-179`).
|
||||
- What RN does: `FilesScreen` keeps `allNodes`/`path` in component state and loads only on mount (`src/screens/FilesScreen.tsx:113-114`, `171-173`); `switchAccount` resets contacts/calendar stores but nothing files-related (`src/stores/auth-store.ts:360-380`). Switching accounts while the Files tab is mounted keeps showing the previous account's tree (and any create/upload goes to the new account under a stale `parentId`) until pull-to-refresh.
|
||||
- Fix hint: subscribe to `useAuthStore(s => s.activeAccountId)` in `FilesScreen` and reset `path`/`selection` + call `loadFiles()` on change, or move the node cache into a store that `switchAccount` resets.
|
||||
|
||||
- [x] **Files capability gated on session capabilities, not the account's** — fixed in a0ce925 — `P3` — `bugfix-parity` (changelog 1.7.5 "Hide Files when the account lacks the filenode capability (#563)")
|
||||
- What WEB does: `supportsFiles(accountId)` checks `account.accountCapabilities` (or non-personal) and `probeFileNodeSupport` refuses to probe when the server advertises filenode but the account doesn't (`lib/jmap/client.ts:6418-6458`).
|
||||
- What RN does: `useHasFiles()` checks `session.capabilities` (`src/lib/capabilities.ts:33-35`); `App.tsx:184-193` disables the tab from that. An account whose `jmap-file-node-*` permissions were revoked still gets an enabled Files tab that errors on load.
|
||||
- Fix hint: check `session.accounts[filesAccountId].accountCapabilities[CAPABILITIES.FILES]` (or `!isPersonal`) in `useHasFiles`.
|
||||
|
||||
- [x] **No upload size limit from server config** — fixed in c0e1d6b — `P3` — `bugfix-parity` (changelog 1.4.10 "Files: Use dynamic server-configured maximum upload sizes")
|
||||
- What WEB does: filters oversized files against `client.getMaxSizeUpload()` and toasts `file_too_large` (`components/files/files-app.tsx:311-329`; `lib/jmap/client.ts:4179-4182`).
|
||||
- What RN does: `startUpload` passes the picked asset straight to `uploadFileNode` (`src/screens/FilesScreen.tsx:345-381`); `grep -ri maxSizeUpload src` → nothing. The server returns an opaque 4xx instead.
|
||||
- Fix hint: read `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` and compare with `asset.size` before uploading.
|
||||
|
||||
- [x] **Upload buffers whole file in memory, no progress, no cancel** — fixed in a0ce925 — `P2` — `partial` (changelog 1.4.12 "Stream WebDAV PUT uploads (#162)", 1.7.2 "Report real upload progress (#333)")
|
||||
- What WEB does: `uploadBlob(file, { signal, onProgress })` with XHR progress and an `AbortController`; progress bar with percent and Cancel (`stores/file-store.ts:591-658`; `components/files/file-browser.tsx:1178-1212`).
|
||||
- What RN does: `uploadBlob` reads the entire file with `new File(uri).bytes()` and posts an `ArrayBuffer` (`src/api/blob.ts:22-31`); `FilesScreen` shows only a spinner in the header button (`:460-464`). Large videos/PDFs from the document picker can exhaust memory; the user cannot cancel.
|
||||
- Fix hint: use `expo-file-system` `uploadAsync`/`FileSystem.createUploadTask` (supports progress callbacks and cancellation, streams from disk) for the JMAP upload URL, then `FileNode/set`.
|
||||
|
||||
- [x] **Single-file upload only; no duplicate-name handling** — fixed in c0e1d6b — `P3` — `partial`
|
||||
- What WEB does: multi-select input (`file-browser.tsx:1135-1141`), per-file progress `current/totalFiles`, `getUniqueName` appends " (1)" when a name already exists (`stores/file-store.ts:211-219`, `622-628`).
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [ ] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — deferred: the OS viewer/share sheet stays the mobile answer for now; no inline viewer ported
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
- [x] **Move (to folder / to parent), cut/copy/paste, duplicate, new text file, undo missing** — fixed in c0e1d6b — `P2` — `missing` — Move to… (folder picker) and Duplicate done; deferred: cut/copy/paste, new text file, undo (lower priority on mobile)
|
||||
- What WEB does: `moveToFolder`, `moveToParent`, `cutResources`/`copyResources`/`pasteResources` (copy via `copyFileNode` = new node reusing the blobId), `duplicateResource`, `createTextFile`, `undoLastAction` with toast Undo (`stores/file-store.ts:825-933`, `1006-1015`; `lib/jmap/client.ts:6787-6830` `copyFileNode`; context menu `file-browser.tsx:1674-1801`; drag-drop onto folders and ".." rows).
|
||||
- What RN does: actions sheet offers Preview/Share, Save to device, Sharing & access, Rename, Delete only (`src/screens/FilesScreen.tsx:804-858`); `src/api/files.ts` has `updateFileNode(id, { parentId })` (`:149-160`) so move is one call away, but no copy helper.
|
||||
- Fix hint: add "Move to…" (folder picker built from `allNodes` folders, then `updateFileNode(id, { parentId })`) and "Duplicate" (`FileNode/set create` with original `blobId/type/size`, mirroring WEB `copyFileNode`). Cut/copy/paste and undo are lower priority on mobile.
|
||||
|
||||
- [x] **Favorites, recent files, search/filter, details pane missing** — fixed in c0e1d6b — `P3` — `missing` — search field and a type/size/modified line in the actions sheet done; deferred: favorites/recent (optional)
|
||||
- What WEB does: favorites and recent (pruned against server nodes on refresh, changelog 1.4.12 #146) persisted in localStorage (`stores/file-store.ts:243-248`, `518-527`, `988-1004`); in-list search (`file-browser.tsx:494-525`, `1106-1132`); details sidebar with type/size/modified/path (`:1929-1969`).
|
||||
- What RN does: none of these (`FilesScreen.tsx` has no search state; settings has no favorites store).
|
||||
- Fix hint: a header search field filtering `visibleFiles` is cheap; details can be a row in the actions sheet; favorites/recent optional.
|
||||
|
||||
- [x] **Multi-select batch download; batch delete only** — fixed in c0e1d6b — `P3` — `partial`
|
||||
- What WEB does: multi-select toolbar offers Download (N) and Delete (N) (`file-browser.tsx:979-999`).
|
||||
- What RN does: selection header offers Delete only (`src/screens/FilesScreen.tsx:405-427`).
|
||||
|
||||
- [x] **Sort direction/key only changeable in Settings; no in-screen column sort** — fixed in c0e1d6b — `P3` — `partial`
|
||||
- What WEB does: clickable Name/Size/Modified headers toggle sort (`file-browser.tsx:814-828`, `1529-1546`).
|
||||
- What RN does: sort comes from `filesDefaultSortKey/Dir` settings only (`FilesScreen.tsx:134-135`, `210-231`).
|
||||
- Fix hint: a sort chip in the header cycling name/size/modified + direction; fix the `modified` property first.
|
||||
|
||||
- [x] **Settings exposed but ignored: folder layout "Sidebar" and "Show thumbnails"** — fixed in c0e1d6b — `P3` — `rn-only-bug` — thumbnails implemented (authenticated Image), folder-layout radio removed (sidebar is N/A on a phone)
|
||||
- What WEB does: `folderLayout: 'sidebar'` renders `FolderTreeSidebar` and hides folders from the list (`file-browser.tsx:497-500`, `1223-1269`); `showThumbnails` renders image thumbnails (`:272-297`, `:1494-1496`).
|
||||
- What RN does: `FilesSettings.tsx:161-171`, `219-224` offer both toggles and preview them, but `FilesScreen` never reads `filesFolderLayout` or `filesShowThumbnails` (only `showIcons/coloredIcons/showHiddenFiles/sort/view`, `:131-136`).
|
||||
- Fix hint: either implement thumbnails (`<Image source={{ uri: getFileNodeDownloadUrl(node), headers: { Authorization } }}>` for image extensions) and drop the folder-layout radio (sidebar is N/A on a phone), or hide both settings.
|
||||
|
||||
- [ ] **Legacy flat-name migration not run on RN** — `P3` — `missing` (changelog 1.7.3 #379; WEB `stores/file-store.ts:286-479`) — deferred: WEB runs the one-time migration; not ported to mobile
|
||||
- What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`).
|
||||
- What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`.
|
||||
|
||||
- [ ] **Deep link `/files/<folder>` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — deferred: belongs to the navigation area (RN has no linking config yet)
|
||||
- What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`).
|
||||
- What RN does: no React Navigation `linking` config anywhere (`grep -rn "linking" src App.tsx` → none); `Files: undefined` in `src/navigation/types.ts:35`.
|
||||
- Fix hint: belongs to the navigation area; when linking is added, map `/files/*` to `Files` with a `path` param and walk `allNodes` by names.
|
||||
|
||||
- [x] **Hard-coded English throughout Files screen and ShareSheet** — fixed in c0e1d6b — `P3` — `rn-only-bug`
|
||||
- What WEB does: everything via `files.*` locale keys (`locales/en/common.json` `files` has ~120 keys incl. `share`, `shared`, `shared_with_me`, `shared_by`).
|
||||
- What RN does: `FilesScreen.tsx` ("New folder", "Delete this item?", "Shared by", "Upload unavailable"…, e.g. `:148`, `:310`, `:521`, `:621`, `:722-727`, `:825-849`) and `ShareSheet.tsx` (`:41-45`, `:160-166`, `:175`, `:219-242`) use literals, even though RN `locales/en/common.json` already has a `files` block (`:1513`) with most of these keys.
|
||||
- Fix hint: wire `useLocaleStore(s => s.t)` with the existing keys; add `share/shared/shared_with_me/shared_by` from WEB.
|
||||
|
||||
- [ ] **Stability warning banner not shown** — `P3` — `partial` — deferred: product call, not shown on mobile
|
||||
- What WEB does: persistent yellow "stability_warning" above the browser (`files-app.tsx:617-620`; changelog 1.4.10 "Update file feature disabled messages and add stability warnings").
|
||||
- What RN does: nothing. Optional; product call.
|
||||
|
||||
- [ ] **Storage quota not surfaced in Files** — `P3` — `missing` (partly N/A) — N/A: quota belongs to the Account/Settings area, Files shows none (like WEB's mobile layout)
|
||||
- What WEB does: passes `quota` from the email store into the navigation rail beside Files (`files-app.tsx:55`, `:569`); changelog 1.7.5 "Storage quota not shown with Stalwart (#577)".
|
||||
- What RN does: `FilesScreen` shows no quota. Whether RN shows quota anywhere is outside this area; if it exists in Settings/Account, this is N/A.
|
||||
|
||||
- [x] **README still says "file storage - UI stubs only"** — fixed in c0e1d6b — `P3` — `rn-only-bug` (docs)
|
||||
- `repos/react-native/README.md:30` lists filters, S/MIME, plugins, themes and file storage as stubs; filters, vacation and files are real implementations (`src/api/files.ts`, `src/screens/FilesScreen.tsx`, `src/components/files/ShareSheet.tsx`, `src/api/__tests__/files.test.ts`). Update the README when the items above land.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Sieve parser/generator/types are a faithful port up to 1.7.2: `diff -w --strip-trailing-cr lib/sieve/parser.ts repos/react-native/src/lib/sieve/parser.ts` shows only the multi-value/attachment hunks (and `debug.warn` → `console.warn`). Included and identical: `@metadata` JSON round-trip, external-rule parsing with origin labels (Roundcube/Nextcloud/etc., changelog 1.4.14 #201), Nextcloud marker regions preserved verbatim, `# Rule:` dedupe of Bulwark blocks that failed to re-parse (1.7.0 "literal braces" fix, `parser.ts` `findBodyOpenBrace` + `filteredExternal`), vacation-only script detection, `externalRequires` merge, `INBOX` canonical path (1.7.1 #313: `FilterRuleModal.tsx:57`), all 10 action types, `computeRequires` (fileinto/copy/imap4flags/reject/body/vacation), `stop` folding into `stopProcessing`.
|
||||
- Filter store semantics identical: skip server-managed `vacation` script (1.4.10), keep activation via `onSuccessActivateScript` on create/update (1.4.10), external/opaque rules read-only, bulwark rules kept contiguous before external ones, `addRule/updateRule/deleteRule/reorderRules/toggleRule`, rollback on failed save, opaque banner with "Open raw editor" / two-step "Reset to visual builder", `SieveScript/validate` via uploaded blob with error-method handling, `createSieveScript('filters')`.
|
||||
- Filter rule editor: name/match type/conditions (from/to/cc/subject/header+name/size/body)/comparators/actions (move & copy folder picker from own non-shared mailboxes, forward, reject text, add_label from keywords store, mark read/star/discard/keep/stop)/stop-processing; empty-name/condition/action validation; comparator reconciliation on field change; default first mailbox for move/copy. Reorder via up/down chevrons instead of drag (fine on mobile). Delete uses a native confirm. Mailboxes are loaded at login (`src/stores/auth-store.ts:84`), so WEB fix #485 (load mailboxes when Filters opened directly) is not needed.
|
||||
- Raw Sieve editor: warning banner, Validate with server errors, two-step confirm save, re-seeds on open, atomic `setOpaqueScript`.
|
||||
- Vacation: `VacationResponse/get|set` on singleton with `core+mail+vacationresponse` using, default object when the list is empty, capability gating (`isVacationSupported`), enable toggle + status pill, subject, plain-text body, preview, end-before-start warning, empty-body warning, error surfacing.
|
||||
- Files API: folder detection `blobId == null` (`src/api/files.ts:12-14`), `FileNode/get ids:null` (not `/query`, which drops folders), `shareWith/myRights` requested explicitly, `principals:owner` in `using`, real-hierarchy folder create without blob/type/size, cascade delete with `onDestroyRemoveChildren`, MIME-type >30 chars → `application/octet-stream` (1.4.12), cross-account "shared with me" aggregation with `accountId:nodeId` namespacing and owner-routed download URLs, shared subtrees browsable and writes hidden inside them, `Share2`/`Users` badges, ShareSheet with the same read/readWrite/manager presets as WEB `FILE_PRESETS`, custom-rights detection, principal search excluding self, revoke, refresh after change; unit tests in `src/api/__tests__/files.test.ts`.
|
||||
- Files UI: list/grid toggle persisted to settings, folder breadcrumb, pull-to-refresh, hidden-file filter, colored/plain icons, long-press multi-select (own nodes only), rename/new-folder prompts rejecting `/`, delete confirmation naming folder cascade, path stack pruned when a folder disappears, Files tab disabled when the capability is missing.
|
||||
|
||||
## N/A on mobile
|
||||
- Drag-and-drop upload / whole-folder upload (`uploadFolder`, `getDroppedFilesAndFolders`), drag-out of files, marquee selection, keyboard shortcuts (Ctrl+A/C/X/V, F2, Delete, Backspace), right-click context menus, resizable folder-tree sidebar, breadcrumb right-click dropdown, `?preview=` window title updates, `window.open` blob preview safety list (`isMimeTypeSafeForInlinePreview`).
|
||||
- WebDAV proxy client (`lib/webdav/client.ts`, `stores/webdav-store.ts`) - WEB itself only uses it for calendar collections now; the Files app is JMAP-only, so RN correctly has no WebDAV code.
|
||||
- Pro-shell cross-account Files picker / `accountFolders` / `__account_root__` breadcrumb sentinel, `filesEnabled` admin policy gate (RN has no policy store), plugin hooks `onSieveScriptGenerate`/`onFiltersSave`.
|
||||
- Drag-to-reorder filter rules (RN uses chevrons), line-number gutter in the Sieve editor.
|
||||
- "Attach from Files" in the composer and "Save attachment to Files" in the viewer: neither client implements these (WEB composer only takes local files, `components/email/email-composer.tsx:1391-1555`; no `createFileNode` call outside the Files store), so nothing to port.
|
||||
@@ -0,0 +1,347 @@
|
||||
# Settings & settings sync, push notifications, i18n, themes, updates, plugins, misc UI
|
||||
|
||||
All paths below are relative to WEB root `the webmail repo` unless prefixed `RN:` (= `repos/react-native/`). Line numbers are from the tree as of 2026-08-29.
|
||||
|
||||
## Summary
|
||||
|
||||
Settings parity is roughly "half the keys, a third of the behaviour": RN persists 77 keys (`RN: src/stores/settings-store.ts:102-230`) against WEB's ~110 (`stores/settings-store.ts:540-735`), but 16 of the RN keys are stored and never read anywhere outside the settings UI (stub toggles), several defaults silently diverge from WEB, and there is no cross-device sync at all (native issue #1) because WEB sync is a webmail-server feature (`/api/settings`, cookie-bound, AES-GCM with `SESSION_SECRET`) that a direct-to-JMAP native client cannot reach. Push is the biggest functional gap: RN subscribes to `Email`/`Mailbox` state changes (WEB only `EmailDelivery`), has no `emailPush` spam filter, ignores the message ids the relay forwards and instead guesses "newest unread in Inbox" (which produces a wrong notification when the newest unread is read elsewhere), never matches the relay payload to an account (scans for a field the relay does not send), has no device list / revoke / force-recreate, and is Android/FCM-only (no iOS, no UnifiedPush; native issues #44/#45/#48). i18n in RN is a flat-lookup shim: 15 of WEB's 27 locales, no interpolation, no plurals, no RTL, vendored catalogs six weeks stale, and 84 of 94 screen/component files never call `t()` at all (every settings pane except Language and Filters is hard-coded English). Themes/plugins/S-MIME/sidebar apps are visual stubs whose controls persist state nobody reads; the update path is a working Android sideloader with no severity concept and a dismissable banner (WEB's security/deprecated notices are non-dismissable).
|
||||
|
||||
## Settings inventory
|
||||
|
||||
Legend: RN? = key exists in RN store; Honored = read by RN app code outside `stores/settings-store.ts` and `components/settings/*` (verified by grep, 2026-08-29). "Owner" names the agent that covers the behaviour when it is not this area.
|
||||
|
||||
| WEB key (default) | RN key (default) | RN? | Honored | Notes |
|
||||
|---|---|---|---|---|
|
||||
| fontSize (medium) | fontSize (medium) | yes | partial | Only `useTypography()` callers scale: EmailListScreen, SidebarDrawer, CalendarSidebarDrawer (`RN: src/theme/dynamic.ts:47`); 89 files import static `typography` from tokens |
|
||||
| density (regular) | density (regular) | yes | yes | EmailListScreen via `useDensity()` |
|
||||
| animationsEnabled (true) | animationsEnabled (true) | yes | partial | Only via `useAnimDuration` in 3 files; 18 files call `Animated.timing` with fixed durations (UndoSnackbar, SwipeableRow, every sheet) |
|
||||
| messageListOrder / messageListOrderScope (#718) | mailSortAscending (false) | partial | yes | Single boolean; native issue #5. Owner: mail-list agent |
|
||||
| dateFormat (smart) | dateFormat (smart) | yes | yes | `RN: src/lib/date-format.ts` |
|
||||
| dateLocale (auto) | – | no | – | Regional numeric order (1.7.x "User-selectable regional date format") missing |
|
||||
| timeFormat (24h) | timeFormat (24h) + calendarTimeFormat (24h) | yes | yes | RN has two independent time-format settings; WEB has one |
|
||||
| firstDayOfWeek (1) | calendarFirstDayOfWeek (1) | yes | yes | RN type is `0 \| 1`, WEB also allows 6 (Saturday) |
|
||||
| timeZone (auto, #755) | – | no | – | Missing |
|
||||
| markAsReadDelay (0) | markAsReadDelay (0) | yes | yes | |
|
||||
| deleteAction (trash) | deleteAction (trash) | yes | yes | email-store |
|
||||
| permanentlyDeleteJunk (false) | same | yes | yes | |
|
||||
| returnToListAfterAction (true) | – | no | – | Owner: mail viewer agent |
|
||||
| showPreview (true) | showPreview (true) | yes | yes | |
|
||||
| mailLayout (split) | mailLayout (split) | yes | **no** | ReadingSettings offers Split/Focus, nothing reads it. N/A on phone; remove the control or hide it |
|
||||
| emailsPerPage (50) | emailsPerPage (25) | yes | yes | Default differs |
|
||||
| externalContentPolicy (ask) | same | yes | yes | EmailBodyView |
|
||||
| messageSpacing (auto), plainTextFont (sans) | – | no | – | Owner: viewer agent |
|
||||
| mailAttachmentAction / attachmentPosition | same | yes | yes | |
|
||||
| emailAlwaysLightMode (false) | same | yes | yes | EmailBodyView:471 |
|
||||
| archiveMode (single) | same | yes | yes | |
|
||||
| hoverActions* | – | no | – | N/A on touch |
|
||||
| swipeRightAction (archive) / swipeLeftAction (delete) | swipeRightAction (read) / swipeLeftAction (archive) + swipeMode | yes | yes | Defaults differ (WEB right=archive,left=delete; RN right=read,left=archive). RN adds `pin`, `move`, `reveal` mode; WEB adds `spam`. Owner: mail-list agent |
|
||||
| autoSaveDraftInterval, sendConfirmation, defaultReplyMode, rtlEditingSupport, subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, requestReadReceiptDefault, readReceiptResponse, emptySubjectWarningEnabled | – | no | – | Owner: compose agent |
|
||||
| autoSelectReplyIdentity (false) | same (**true**) | yes | yes | Default differs |
|
||||
| plainTextMode (false) | same | yes | yes | |
|
||||
| sendDelaySeconds (0) | same (0, any number) | yes | yes | RN does not validate to 0/10/30/60 like WEB (`stores/settings-store.ts:901`) |
|
||||
| attachmentReminderEnabled / attachmentReminderKeywords (multilingual list, :670-697) | same (4 English words, :255) | yes | yes | RN default list is English-only |
|
||||
| sessionTimeout (0) | – | no | – | Owner: auth agent |
|
||||
| trustedSenders ([]) | same | yes | yes | RN `addTrustedSender` does not strip `Name <addr>` (WEB :1008-1016) |
|
||||
| trustedSendersAddressBook (null → auto true) | same (**false**) | yes | partial | Read by EmailBodyView:470 but there is no toggle in ContentSendersSettings and no auto-enable, so it is permanently off |
|
||||
| expandedFilterView | filtersExpandedView | yes | yes | Only read inside FilterSettings itself (fine) |
|
||||
| showTimeInMonthView (false) | calendarShowTimeInMonth (**true**) | yes | **no** | Stored, never read. Owner: calendar agent |
|
||||
| showWeekNumbers | calendarShowWeekNumbers | yes | yes | |
|
||||
| calendarHoverPreview | same | yes | **no** | N/A on touch; hide the control |
|
||||
| enableCalendarTasks / showTasksOnCalendar | same | yes | yes / **no** | showTasksOnCalendar stored, never read |
|
||||
| showBirthdayCalendar (false) | same (**true**) | yes | yes | Default differs |
|
||||
| birthdayCalendarColor | – | no | – | Owner: calendar agent |
|
||||
| sharedCalendarColors | same | yes | yes | |
|
||||
| groupContactsByLetter | same | yes | yes | |
|
||||
| emailNotificationsEnabled (true) | same | yes | yes | App.tsx:336, push-background-task.ts:32 |
|
||||
| emailNotificationSound (true) | same | yes | **no** | Stored, never read; Android channel sound is whatever the OS default is |
|
||||
| notificationSoundChoice (default/cheerful/involved/swift/relax) | same (default/chime/ping/pop/none) | yes | **no** | Different option set, never read, preview button has no `onPress` (`RN: NotificationSettings.tsx:205-210`) |
|
||||
| pushRelayUrl ('' = admin default) | (AsyncStorage `push:relayBaseUrl:v1`, free text) | separate | yes | See push findings |
|
||||
| calendarNotificationsEnabled / calendarNotificationSound | same | yes | **no** | Stored, never read (calendar-alerts lib does not consult them). Owner: calendar agent for the alarm side |
|
||||
| calendarInvitationParsingEnabled | same | yes | yes | CalendarInvitationBanner |
|
||||
| protocolOpenMode | – | no | – | N/A (web protocol handlers) |
|
||||
| toolbarPosition | – | no | – | N/A |
|
||||
| showToolbarLabels | same | yes | yes | EmailThreadScreen |
|
||||
| hideAccountSwitcher, showRailAccountList, proInterface | – | no | – | N/A |
|
||||
| enableUnifiedMailbox, unifiedCrossAccount, enableCross*View, allMailFolderIds | – | no | – | RN has a UnifiedInbox screen without these switches. Owner: mail-list agent |
|
||||
| includeGroupInUnified (true) | same (**false**) | yes | yes | Default differs |
|
||||
| preferredIdentityIds (per account, #507) | – | no | – | Owner: identity agent |
|
||||
| disableThreading | same | yes | yes | |
|
||||
| senderFavicons | same | yes | yes | |
|
||||
| showAvatarsInJunk, faviconUnreadBadge, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount | – | no | – | faviconUnreadBadge N/A; others Owner: mail-list/sidebar agent |
|
||||
| folderIcons, emailKeywords, nestedTags | – (keywords-store) | separate | – | Owner: mail agent |
|
||||
| hideInlineImageAttachments / attachmentImagePreviewsEnabled | yes / no | | | |
|
||||
| sidebarApps, keepAppsLoaded | same | yes | **no** | Never rendered anywhere (see Plugins section) |
|
||||
| onboardingCompleted, tourCompleted, showOnboardingOnNewDevices | – | no | – | N/A (no tour) |
|
||||
| emailDownloadTemplate, attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics | emailExportTemplate, attachmentExportTemplate, exportSpaceReplacement, exportLowercase, exportStripDiacritics | yes | yes | `RN: src/lib/email-export.ts` |
|
||||
| bundleDownloadTemplate, filenameCollapseSeparators, postExportAction | – | no | – | Owner: mail viewer/export agent |
|
||||
| debugMode, debugCategories | (React local state only) | no | **no** | `RN: AboutDataSettings.tsx:61-63` — toggles live in `useState`, lost on unmount, nothing reads them |
|
||||
| settingsSyncDisabled | (local state) | no | **no** | `RN: AboutDataSettings.tsx:65,246-248` — a "Settings sync" switch that does nothing |
|
||||
| – | theme (system) | RN-only | yes | WEB keeps this in theme-store |
|
||||
| – | activeThemeId (null) | RN-only | **no** | Never read |
|
||||
| – | smimeDefaultEncrypt / smimeRememberUnlocked / smimeAutoImport | RN-only | **no** | Stubs |
|
||||
| – | pluginEnabled ({}) | RN-only | **no** | Stub |
|
||||
| – | files* (8 keys) | RN-only | 6 of 8 | filesFolderLayout, filesShowThumbnails never read. Owner: files agent |
|
||||
| – | bottomQuickActions, swipeMode, offlineCache* | RN-only | yes | |
|
||||
|
||||
RN toggles that are stored but never read (fix: either wire them or remove the control): `mailLayout`, `calendarShowTimeInMonth`, `calendarHoverPreview`, `showTasksOnCalendar`, `emailNotificationSound`, `notificationSoundChoice`, `calendarNotificationsEnabled`, `calendarNotificationSound`, `sidebarApps`, `keepAppsLoaded`, `activeThemeId`, `smimeDefaultEncrypt`, `smimeRememberUnlocked`, `smimeAutoImport`, `pluginEnabled`, `filesFolderLayout`, `filesShowThumbnails`.
|
||||
|
||||
## Findings
|
||||
|
||||
### Settings store and settings sync
|
||||
|
||||
- [ ] **No cross-device settings sync; RN cannot join the WEB sync store as-is (native #1)** — `P2` — `missing` — deferred (needs a server-side store); key-mapping table shipped as SETTINGS_KEY_MAP + DEVICE_LOCAL_KEYS in src/stores/settings-store.ts (8deff66) and used by export/import; the dead 'Settings sync' switch is gone
|
||||
- What WEB does: every settings change (plus theme, locale, templates #825) is debounced 2 s and POSTed to the webmail server's `/api/settings` (`stores/settings-store.ts:1340-1359`, `:72-96`). The server verifies the caller against its own session cookies for any account slot (`app/api/settings/route.ts:71-94`), strips admin-locked keys (`:142-167`) and writes `sha256(username:serverUrl).enc` under `SETTINGS_DATA_DIR`, AES-256-GCM with key `sha256(SESSION_SECRET)` (`lib/settings-sync.ts:12-32,34-53`). On login it GETs the blob and imports it with per-account map merging (`stores/settings-store.ts:1125-1165`, `:942-951`). `proInterface` is device-local (`:147`).
|
||||
- What RN does: persists a flat JSON under AsyncStorage key `webmail:settings:v1` (`RN: src/stores/settings-store.ts:91,393-397`) and nothing else. The AboutData "Settings sync" toggle is `useState` (`RN: AboutDataSettings.tsx:65,246-248`).
|
||||
- Fix hint: RN talks to Stalwart directly and has no webmail session cookie, so it cannot call `/api/settings` (403 identity mismatch, and the deployment's webmail URL is unknown to it). Two realistic designs: (a) store the same JSON blob on the JMAP server as a Blob (`Blob/upload` + a well-known keyword/mailbox message, or a FileNode in the user's Files root such as `/.bulwark/settings.json`) and have WEB read/write the same object so both clients converge — needs a shared key-name mapping (WEB names above) and a `updatedAt` for last-writer-wins; or (b) let the login flow optionally capture the webmail origin (the QR sign-in already comes from webmail) and proxy sync through it with a bearer token. Either way, ship a key-mapping table first (RN `mailSortAscending` ↔ WEB `messageListOrder`, `calendarFirstDayOfWeek` ↔ `firstDayOfWeek`, `emailExportTemplate` ↔ `emailDownloadTemplate`, etc.) and exclude device-local keys (`swipeMode`, `bottomQuickActions`, `offlineCache*`, `theme`?).
|
||||
|
||||
- [x] **Default values silently diverge from WEB** — fixed in 8deff66 — `P3` — `partial`
|
||||
- What WEB does: `emailsPerPage` 50, `autoSelectReplyIdentity` false, `showBirthdayCalendar` false, `showTimeInMonthView` false, `includeGroupInUnified` true, `trustedSendersAddressBook` null→auto-true, swipe right=archive/left=delete, multilingual attachment-reminder keyword list (`stores/settings-store.ts:540-735`).
|
||||
- What RN does: 25, true, true, true, false, false, right=read/left=archive, 4 English keywords (`RN: src/stores/settings-store.ts:232-325`).
|
||||
- Fix hint: align defaults where the behaviour is the same on both clients (attachment keywords, includeGroupInUnified, autoSelectReplyIdentity, showBirthdayCalendar) before any sync exists, otherwise the first sync flips them for existing users.
|
||||
|
||||
- [ ] **`trustedSendersAddressBook` can never be turned on in RN** — `P3` — `partial`
|
||||
- What WEB does: `null` resolves to `true` on first connect when the server has contacts; the Content & Senders tab has a toggle (`components/settings/content-senders-settings.tsx`, `updateSetting('trustedSendersAddressBook'`).
|
||||
- What RN does: default `false` (`RN: src/stores/settings-store.ts:239`), read by `EmailBodyView.tsx:470,489` but `ContentSendersSettings.tsx` has no control for it.
|
||||
- Fix hint: add the toggle; treat undefined as auto-on when `useHasContacts()`.
|
||||
|
||||
- [x] **`addTrustedSender` does not normalise `Name <addr>`** — fixed in 8deff66 — `P3` — `bug`
|
||||
- What WEB does: strips display-name angle form before storing/comparing (`stores/settings-store.ts:1008-1035`).
|
||||
- What RN does: lowercases and trims only (`RN: src/stores/settings-store.ts:471-489`); callers that pass a formatted address store a value that will never match.
|
||||
- Fix hint: port the angle-bracket regex into `add/remove/isSenderTrusted`.
|
||||
|
||||
- [x] **`sendDelaySeconds` and other enum keys not sanitised on hydrate** — fixed in 8deff66 — `P3` — `bug`
|
||||
- What WEB does: rejects/resets invalid `sendDelaySeconds`, `messageListOrder`, `messageListOrderScope`, `subAddressDelimiter` on import and in `migrate` (`stores/settings-store.ts:898-930,1207-1209`).
|
||||
- What RN does: `mergeWithDefaults` only checks `typeof` (`RN: src/stores/settings-store.ts:399-415`); a corrupt `swipeLeftAction: "foo"` or `density: "x"` flows into the UI.
|
||||
- Fix hint: add per-key validators (allowed-value sets) to `mergeWithDefaults`; `normalizeBottomQuickActions` is the pattern.
|
||||
|
||||
- [x] **Reset-to-defaults only resets three keys; no export/import** — fixed in 8deff66 — `P3` — `partial`
|
||||
- What WEB does: `resetToDefaults` restores every key; About & Data offers Export/Import JSON (feature-gated) and "Refresh cached data" (`components/settings/about-data-settings.tsx:77-129,171-220`, `lib/clear-cached-data.ts`).
|
||||
- What RN does: `handleReset` resets `externalContentPolicy`, `senderFavicons` and trusted senders only (`RN: AboutDataSettings.tsx:104-110`); no export/import; "Clear cache" only clears the offline body cache.
|
||||
- Fix hint: add `resetToDefaults` to the store (`set(DEFAULT_PERSISTED)` + persist), an export via `expo-sharing` and import via `expo-document-picker` using WEB's export shape so files round-trip between clients; add a "refresh cached data" that clears `email-snapshot`/contacts/calendar caches without logging out.
|
||||
|
||||
- [x] **Debug mode / categories are inert local state; no debug/logger abstraction** — fixed in 8deff66 — `P3` — `missing` — src/lib/debug.ts; dead Debug tab removed in 3d0d440
|
||||
- What WEB does: `debugMode`/`debugCategories` persisted and honoured by `lib/debug.ts:8-13`; `lib/error-reporting.ts` funnels error-boundary reports; `lib/logger.ts` server side. Debug tab gated by admin (`settings-app.tsx:766`).
|
||||
- What RN does: `AboutDataSettings.tsx:61-66,226-244` keeps toggles in `useState` with categories (`sync`, `render`) that exist nowhere; the "Debug" tab is `implemented: false` (`SettingsScreen.tsx:111`); code uses raw `console.warn`.
|
||||
- Fix hint: persist `debugMode`/`debugCategories`, add `src/lib/debug.ts` mirroring WEB's category API, route the `[push]`, `[settings-store]`, `[updates-store]` warnings through it, and drop the dead Debug tab or implement a log viewer.
|
||||
|
||||
- [x] **Settings panes are hard-coded English (no i18n)** — fixed in 22697fd — `P2` — `partial` — all panes I own use t(); RN-only keys harvested into locales/rn/en.json (npm run i18n:harvest); Calendar/Contacts/ContentSenders/Filter/Vacation/Files/Folder panes belong to other agents
|
||||
- What WEB does: every settings component uses `useTranslations` with keys in `locales/*/common.json`.
|
||||
- What RN does: only `LanguageSettings.tsx` and `FilterSettings.tsx` call `t()`; the other 24 settings components plus `settings-section.tsx` have literal English strings (grep count 0 for `t(` in each). `SettingsScreen.tsx:184-187` translates group/tab labels only, and the "Experimental"/"Not implemented"/"Unavailable" badges (`:297,270-273`) are literal.
|
||||
- Fix hint: the vendored catalog already contains `settings.*` keys for almost every label (WEB keys); wrap each label/description in `t('settings.<tab>.<key>', 'fallback')`.
|
||||
|
||||
- [x] **Settings tabs shown regardless of server capability / admin policy** — fixed in 3ffcef2 — `P3` — `partial` — Sieve/Vacation gating via useHasSieve/useHasVacation (filters agent's commit)
|
||||
- What WEB does: hides Vacation/Filters when Sieve is absent, Templates/Keywords/Sidebar apps/Plugins/Themes/Debug behind admin feature flags, Security behind Stalwart features (`components/settings/settings-app.tsx:724-766`); settings can be `locked`/hidden per policy (`appearance-settings.tsx:89-119`).
|
||||
- What RN does: hides only calendar/contacts/files by capability and updates by platform (`RN: SettingsScreen.tsx:142-179`); Vacation and Filters render even without Sieve; `SettingItem` has a `locked` prop (`settings-section.tsx:68-86`) that nothing sets. Admin policy is N/A (no webmail server), but Sieve gating is not.
|
||||
- Fix hint: gate `vacation`/`filters` on `CAPABILITIES.SIEVE` in the session like WEB's `supportsSieve`/`supportsVacation`.
|
||||
|
||||
### Push notifications
|
||||
|
||||
- [x] **RN subscribes to `Email` and `Mailbox` state changes, not just `EmailDelivery`** — fixed in 00faceb — `P2` — `bugfix-parity`
|
||||
- What WEB does: `PUSH_TYPES = ['EmailDelivery']` because `Email` fires on every mutation and produced spurious notifications (`lib/web-push.ts:39-44`; changelog 1.5.x "Scope new-mail notifications to genuine inbox deliveries").
|
||||
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`RN: src/lib/push-notifications.ts:104`). Every read/flag/move/draft on any client wakes the device through FCM and runs the headless task.
|
||||
- Fix hint: change to `['EmailDelivery']` and bump the subscription (the refresh path only patches `expires`, `:411-429`; add a `types` mismatch check like WEB `:369-383` so existing subscriptions get corrected).
|
||||
|
||||
- [x] **"Newest unread in Inbox" heuristic notifies the wrong message** — fixed in 00faceb — `P2` — `rn-only-bug`
|
||||
- What WEB does: the service worker uses the `emailIds` the relay forwards (EmailPush) or, on StateChange, asks `/api/push/preview?accountId=&emailId=` (`app/api/push/preview/route.ts:88-119`).
|
||||
- What RN does: `processAccountForPush` queries `notKeyword:$seen` in Inbox, limit 1, and notifies it unless it equals `lastNotified` (`RN: src/lib/push-background-task.ts:151-164`). Because RN also subscribes to `Email` changes, reading the newest unread message on another device triggers a push, the query now returns the *next older* unread message, which was never `lastNotified`, so the user gets a notification for old mail. Two messages arriving in one push yield one notification; mail Sieve-filed outside Inbox never notifies; the relay's `emailIds` (`repos/relay/src/payload.ts`, `fcm.ts` data.emailIds) are ignored.
|
||||
- Fix hint: parse `data.emailIds` (JSON string) and `data.kind`; when present, `Email/get` those ids directly and post one notification per id (or a grouped one); fall back to the Inbox query only for legacy `jmap-state-change` payloads, and there compare against a stored set of seen ids rather than a single `lastNotified`.
|
||||
|
||||
- [x] **Per-account matching of the FCM payload never matches** — fixed in 00faceb — `P2` — `rn-only-bug` — local id → JMAP id map in push:jmapAccountIds:v1 (+AccountEntry.jmapAccountId), accountLabel fallback
|
||||
- What WEB does: SW reads `accountId` from the payload's `changed` map (`app/api/push/preview/route.ts:90-99`).
|
||||
- What RN does: `identifyAccountFromFcmData` scans payload values for a locally stored `deviceClientId` (`RN: src/lib/push-background-task.ts:67-87`), but the relay sends `kind, accountLabel, accountId, emailIds, changed` (`repos/relay/src/fcm.ts` message.data) - never the subscription id. Result: every push processes every logged-in account serially inside a 30 s headless budget (`BulwarkPushTaskService.kt` timeout), loading each account's JMAP session.
|
||||
- Fix hint: the relay's `accountId` is the JMAP primary account id; persist each account's JMAP account id in `AccountEntry` (`RN: src/stores/account-store.ts:7-19` has none) or in the push registry (`push:accountIds:v1` → map local id → jmap id) at setup time, and match on that. `accountLabel` (= username) is a weaker second key.
|
||||
|
||||
- [x] **No `emailPush` delivery filter (spam still wakes the device)** — fixed in 00faceb — `P2` — `bugfix-parity`
|
||||
- What WEB does: when the session advertises `urn:ietf:params:jmap:emailpush` (Stalwart ≥ 0.16.16) the subscription carries a per-account filter `notKeyword:$junk AND inMailboxOtherThan:[junk ids]` (`lib/web-push.ts:53-108,491-497`, commit 63fa2d2d) and re-syncs it in the background on every page load (`:668-688`, `components/push-notification-prompt.tsx:119-126`, 1.9.2).
|
||||
- What RN does: `createPushSubscription` has no `emailPush` parameter (`RN: src/api/push.ts:23-57`); the Inbox-only query hides junk from the notification but the wake-up and JMAP round-trip still happen for every spam delivery.
|
||||
- Fix hint: port `buildEmailPushConfig` + `serverSupportsEmailPush` (session capabilities are in `jmapClient.currentSession.capabilities`), add `emailPush` to `createPushSubscription`/`updatePushSubscription`, and extend `refreshSubscriptionExpires` to patch `types`/`emailPush` when they differ.
|
||||
|
||||
- [x] **No device list, per-device revoke, or force-recreate (#841)** — fixed in f6f26f7 — `P2` — `missing`
|
||||
- What WEB does: Notifications tab lists every `PushSubscription` on the account with relay liveness and "this device" marker, lets the user revoke any of them, and "Re-register" destroys and recreates the subscription so revoked shared-mailbox access stops fanning out (`lib/web-push.ts:565-639`, `:143,457-461`; `components/settings/notification-settings.tsx:169-193,251-303`; changelog 1.8.x).
|
||||
- What RN does: "Re-register" calls `setupPushNotifications` which reuses the stored subscription and only refreshes `expires` (`RN: src/lib/push-notifications.ts:345-352`); no list/revoke UI.
|
||||
- Fix hint: add `forceRecreate` to `PushSetupParams`, and a `listPushDevices`/`revokePushDevice` pair (JMAP `PushSubscription/get` + relay `/api/push/active/:id` + `DELETE /api/push/register/:id`) rendered under the relay card.
|
||||
|
||||
- [ ] **Relay URL is a free-text field instead of an admin-curated list; `DEFAULT_RELAY_BASE_URL` not overridable** — `P3` — `partial` — https now required (isValidRelayUrl, 00faceb); deferred: per-account relay storage / discovery
|
||||
- What WEB does: user picks from `resolvePushRelayOptions(policy)` (default + admin list + legacy), never types a URL; admin can lock it (`lib/push-relays.ts:39-80`, `notification-settings.tsx:226-241`).
|
||||
- What RN does: `TextInput` with any `https?://` accepted (`RN: NotificationSettings.tsx:174-184`), stored device-wide in AsyncStorage (`push-notifications.ts:160-175`) so two accounts on different servers share one relay.
|
||||
- Fix hint: keep free text (there is no policy source in RN) but store the relay per account alongside `push:accountIds:v1`, and let `.well-known`/login-time discovery optionally seed it. Not blocking.
|
||||
|
||||
- [x] **Re-register error path is opaque (native #45)** — fixed in 00faceb — `P2` — `bug` — PushSetupError{phase}, relay body in message, guarded getToken, per-account teardown keeps the FCM token; no device repro yet
|
||||
- What WEB does: surfaces `WebPushUnsupportedError` separately and reports precise relay/JMAP failures; reaps only relay-confirmed-dead leftovers (`lib/web-push.ts:466-489`).
|
||||
- What RN does: `setupPushNotificationsInner` calls `native.getToken()` directly (not the guarded `getFcmToken`) so a Firebase rejection (`fcm_token_failed`, typical on de-Googled devices or right after `deleteToken()` during Disable) bubbles up raw; `registerWithRelay` throws `Relay register failed: <status>` without the body (`RN: src/lib/push-notifications.ts:312-313,215-217`); `pollVerificationCode` times out after 75 s with the same message for every cause (`:268`). The UI shows `error.message` verbatim (`NotificationSettings.tsx:95-99`). Disable then immediately Enable also races: `teardownPushNotificationsForAccount` deletes the FCM token when it was the last account (`:477-481`) and the next `getToken()` may be rejected until Firebase re-registers.
|
||||
- Fix hint: read the relay response body into the error, distinguish "no Google Play services" (map to the UnifiedPush finding), skip `deleteToken()` unless the user disables push for all accounts, and log each phase (`token`, `relay`, `jmap create`, `verify`) so the settings pane can show which step failed. Needs a repro on the reporter's device to confirm the exact failure.
|
||||
|
||||
- [ ] **iOS push missing entirely** — `P2` — `missing` — deferred: needs an APNs transport in the relay + iOS token module; the settings pane now states Android-only
|
||||
- What WEB does: Web Push works on iOS 16.4+ once installed to the home screen (`lib/web-push.ts:157-164,394-398`).
|
||||
- What RN does: `getNative()` returns `null` off Android and setup throws "Push notifications require Android" (`RN: src/lib/push-notifications.ts:125-128,303-304`); the relay has only FCM and Web Push transports (`repos/relay/README.md` endpoints).
|
||||
- Fix hint: needs an APNs transport in the relay (`POST /api/push/register/apns` storing a device token, HTTP/2 to `api.push.apple.com` with a `.p8` key) plus an iOS native module (or `expo-notifications`) for the token and a Notification Service Extension to fetch sender/subject over JMAP. Content-blind payload stays the same.
|
||||
|
||||
- [ ] **No non-FCM transport (UnifiedPush, native #44/#48)** — `P3` — `missing` — deferred: relay must accept key-less web registrations + a UnifiedPush connector module
|
||||
- What WEB does: N/A (browser push).
|
||||
- What RN does: FCM only (`BulwarkMessagingService.kt`, `push-notifications.ts:120-128`).
|
||||
- Fix hint: UnifiedPush distributors expose an RFC 8030 endpoint, which is exactly what the relay's `/api/push/register/web` already accepts (`repos/relay/src/server.ts` handleRegisterWeb); VAPID/`keys` are optional in UnifiedPush so the relay must accept a record without `p256dh`/`auth` and send unencrypted (or only the id). Client side: `unifiedpush-react-native`/`org.unifiedpush.android.connector` receiving the message and calling the same headless task.
|
||||
|
||||
- [x] **No "+N more messages" grouping** — fixed in 00faceb — `P3` — `missing`
|
||||
- What WEB does: one notification per account with the newest message as headline and a "+N more" line, tag `bulwark-mail:<accountId>`, click opens inbox when grouped (`public/sw.js:180-216`; changelog 1.8.x).
|
||||
- What RN does: one notification per message id `mail:<emailId>` with no `setGroup`/summary (`RN: BulwarkFcmModule.kt postNotification`).
|
||||
- Fix hint: set `setGroup("bulwark-mail:"+accountId)` on each notification and post a summary notification with `setGroupSummary(true)` and an `InboxStyle` listing; Android does the "+N" rendering.
|
||||
|
||||
- [x] **Foreground FCM message is only logged** — fixed in 00faceb — `P3` — `partial`
|
||||
- What WEB does: SW shows the notification regardless; in-app state refresh comes from the separate SSE channel.
|
||||
- What RN does: `addMessageListener` handler just `console.log`s (`RN: App.tsx:300-305`); since the Kotlin service skips the headless task in the foreground (`BulwarkMessagingService.kt:26-31`) no notification is posted and no store refresh happens if SSE happens to be down (SSE fallback is 5 s polling, so the gap is small).
|
||||
- Fix hint: on `fcm:message`, call `useEmailStore.getState().handleStateChange(JSON.parse(payload.data.changed))` (relay sends it) and optionally post an in-app banner.
|
||||
|
||||
- [x] **No push onboarding prompt** — fixed in f6f26f7 — `P3` — `missing`
|
||||
- What WEB does: after the PWA install prompt, a per-account dismissable "enable notifications" card appears 1 s after login unless already enabled (`components/push-notification-prompt.tsx:128-177`; changelog 1.8.0 "Background notification onboarding").
|
||||
- What RN does: push only starts if `getStoredRelayBaseUrl()` is non-null (`RN: App.tsx:353-354`), i.e. the user must find Settings → Notifications and press Enable; nothing invites them.
|
||||
- Fix hint: on first authenticated launch, prompt once (with "don't ask again" persisted per account) and call `setupPushNotifications({ relayBaseUrl: DEFAULT_RELAY_BASE_URL })`.
|
||||
|
||||
- [x] **Notification sound settings are dead** — fixed in f6f26f7 — `P3` — `partial` — controls removed, channel hint shown; store keys dropped in the settings-store pass
|
||||
- What WEB does: `notificationSoundChoice` picks one of 5 sounds with preview; email/calendar sound toggles gate playback (`lib/notification-sound.ts`, `notification-settings.tsx:305-331`).
|
||||
- What RN does: `NotificationSettings.tsx:203-250` renders a sound `Select` (values `chime/ping/pop/none` that exist nowhere), a preview button without `onPress`, and email/calendar sound toggles that no code reads; the Android channel `bulwark_mail` uses the default sound and cannot change it after creation (`BulwarkMessagingService.kt:62-78`).
|
||||
- Fix hint: on Android sounds belong to channels: create one channel per sound choice (or open the system channel settings via `Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS`), and remove the toggles that cannot be honoured.
|
||||
|
||||
### i18n and locales
|
||||
|
||||
- [x] **12 of WEB's 27 locales missing in RN** — fixed in 990cd84 — `P2` — `missing`
|
||||
- What WEB does: `ar ca cs da de en es fa fr he hu it ja ko lv mn nb nl pl pt ro ru sk tr uk zh zh-TW` (`i18n/routing.ts:15`).
|
||||
- What RN does: `SUPPORTED_LOCALES` has 15 (`RN: src/i18n/index.ts:21-37`); `scripts/sync-locales.mjs` copies every WEB folder (`:32-42`) but `locales/` only contains the 15 and the index imports them statically. Missing: ar, ca, da, fa, he, hu, mn, nb, ro, sk, tr, zh-TW.
|
||||
- Fix hint: run the sync script, add the imports/labels, and handle `zh-TW` (region subtag) in `detectDeviceLocale` which only compares `languageCode` (`:45-52`).
|
||||
|
||||
- [x] **Vendored catalogs are stale and drift from WEB** — fixed in 990cd84 — `P2` — `partial` — RN-only keys live in locales/rn/<lang>.json (merged at runtime); sync-locales.mjs --check reports drift
|
||||
- What WEB does: `locales/en/common.json` has 2959 leaf keys (2026-08-29).
|
||||
- What RN does: `RN: locales/en/common.json` last synced 2026-07-22 has 2336 keys; 640 WEB keys are absent; RN en carries 17 keys WEB lacks (hand-added `email_composer.*`) which the 14 other RN locales do not have (de/fr/ja each miss the same 16 keys), and 19 keys RN code calls (`email_composer.schedule_*`, `email_list.no_*_folder`) exist in no catalog, so the inline fallback always shows in every language.
|
||||
- Fix hint: make `sync-locales.mjs` merge instead of overwrite (keep RN-only keys under a separate `locales/rn/<lang>.json` overlay), add a key-parity test like WEB's translation coverage test, and run it in CI.
|
||||
|
||||
- [x] **`translate()` has no interpolation, no plurals, no rich text** — fixed in 990cd84 — `P2` — `partial` — hand-rolled plurals replaced in OfflineBanner/AboutData/Layout/Identity/Templates (8deff66, 22697fd); rich text (tags) not needed by any RN string
|
||||
- What WEB does: next-intl ICU messages (`{count, plural, ...}`, `{name}` args).
|
||||
- What RN does: plain nested lookup returning the raw string (`RN: src/i18n/index.ts:54-71`); any WEB string with `{placeholder}` renders literally, and plurals are hand-rolled English (`OfflineBanner.tsx:22`, `AboutDataSettings.tsx:96,176,190`, `LayoutSettings.tsx:148-149`).
|
||||
- Fix hint: add `t(key, fallback, params)` with `{name}` substitution and a minimal ICU plural (`intl-messageformat` is small and works on Hermes), then replace the hand-rolled plurals.
|
||||
|
||||
- [ ] **No RTL support (ar/he/fa)** — `P2` — `missing` — done in 990cd84: forceRTL/allowRTL on override + restart hint, isLayoutRTL() helper in src/i18n; deferred: SwipeableRow side swap (mail-list agent's file - call isLayoutRTL() there)
|
||||
- What WEB does: `getLocaleDirection` sets `dir=rtl`, logical CSS, JS popovers flip, RTL-aware swipe (`i18n/direction.ts`; changelog 1.7.0-1.7.3 RTL entries).
|
||||
- What RN does: none of the three RTL locales are shipped; no `I18nManager` usage anywhere; `android:supportsRtl="true"` is set (`RN: AndroidManifest.xml:16`) so the OS would mirror layouts when the device locale is RTL, but the app's own locale override cannot force it, and `SwipeableRow` maps left/right physically.
|
||||
- Fix hint: when adding ar/he/fa, call `I18nManager.forceRTL(dir==='rtl')` + `allowRTL` on override change (requires reload), audit `paddingLeft/Right` → `paddingStart/End`, and swap swipe actions when `I18nManager.isRTL`.
|
||||
|
||||
- [ ] **Language list not localized and no "auto" date locale** — `P3` — `partial` — deferred: language labels are native names (webmail parity); timeZone landed as calendarTimeZone (calendar agent); dateLocale needs a locale override threaded through src/lib/date-format.ts call sites in other areas
|
||||
- What WEB does: language picker sorted, flags, `dateLocale` (auto/iso/en-GB/en-US), `firstDayOfWeek`, `timeZone` on the Language tab (`components/settings/language-settings.tsx` `updateSetting('dateLocale'|'firstDayOfWeek'|'timeZone'`).
|
||||
- What RN does: `LanguageSettings.tsx` offers language, `dateFormat`, `timeFormat` only; day-of-week lives under Calendar; no time zone.
|
||||
- Fix hint: add `dateLocale` and `timeZone` keys and thread them through `src/lib/date-format.ts` (`Intl.DateTimeFormat(locale, { timeZone })`).
|
||||
|
||||
### Themes and appearance
|
||||
|
||||
- [ ] **Font size setting is honoured by 3 of ~90 files** — `P3` — `partial` — deferred: Text.defaultProps is gone on the new architecture; needs the static typography import replaced by useTypography() per screen
|
||||
- What WEB does: `--font-size-base` on `:root` scales everything (`stores/settings-store.ts:1264-1274`).
|
||||
- What RN does: `useTypography()` (`RN: src/theme/dynamic.ts:47-68`) is used by EmailListScreen, SidebarDrawer, CalendarSidebarDrawer; the other screens spread the static `typography` from `tokens.ts`, so Small/Large changes almost nothing (the thread reader, compose, settings, calendar, contacts all stay fixed).
|
||||
- Fix hint: either make `tokens.typography` a hook-backed getter or, cheaper, set `Text.defaultProps`/`maxFontSizeMultiplier` and scale via `allowFontScaling` + a root `PixelRatio` factor.
|
||||
|
||||
- [x] **`animationsEnabled` ignored by most animations; no reduce-motion respect** — fixed in 3ae9bbe — `P3` — `partial` — useShouldAnimate ORs in AccessibilityInfo.isReduceMotionEnabled; the 18 fixed-duration Animated.timing calls in other areas' files still need useAnimDuration
|
||||
- What WEB does: `--transition-duration: 0s` when off (`stores/settings-store.ts:1322-1331`) and `@media (prefers-reduced-motion: reduce)` (`app/globals.css:681`).
|
||||
- What RN does: 18 files call `Animated.timing`/`LayoutAnimation` with literal durations (e.g. `UndoSnackbar.tsx:32-42`, every sheet, `SwipeableRow.tsx`); `useAnimDuration` used in 3. `AccessibilityInfo.isReduceMotionEnabled` is never consulted.
|
||||
- Fix hint: route durations through `useAnimDuration` and OR in `AccessibilityInfo.isReduceMotionEnabled()` inside `useShouldAnimate`.
|
||||
|
||||
- [x] **Density preview and other hard-coded dark colours break on the light theme** — fixed in 22697fd — `P3` — `rn-only-bug` — Appearance/Themes/SidebarApps/settings-section/OfflineBanner/SettingsScreen badge use tokens; Plugins pane replaced
|
||||
- What WEB does: preview uses tokens (`components/settings/appearance-settings.tsx:21-64`).
|
||||
- What RN does: `AppearanceSettings.tsx:63` paints read subjects `rgba(250,250,250,0.8)` and `:193` preview text `rgba(161,161,170,0.7)` regardless of theme, so the preview is near-invisible on light. Similar literals: `PluginsSettings.tsx:27-30`, `ThemesSettings.tsx:108`, `SidebarAppsSettings.tsx:86,238,296`, `OfflineBanner.tsx:43` (`#a16207`), `SettingsScreen.tsx:382`.
|
||||
- Fix hint: replace with `c.textSecondary`/`c.mutedForeground`/`c.warningBg` tokens.
|
||||
|
||||
- [x] **`userInterfaceStyle: 'dark'` likely defeats the "System" theme on iOS** — fixed in 5802cae — `P3` — `bug` (verify) — userInterfaceStyle automatic + light/dark splash
|
||||
- What WEB does: `system` follows `prefers-color-scheme` (`stores/theme-store.ts:53-56`).
|
||||
- What RN does: `RN: app.config.js:33` sets `userInterfaceStyle: 'dark'`, which prebuild writes as `UIUserInterfaceStyle = Dark` in Info.plist, so `useColorScheme()` (`src/theme/colors.ts:16`, `App.tsx:220`) always returns `dark` and "System" behaves like "Dark". On Android without `expo-system-ui` (not in `package.json`) the key is ignored with a prebuild warning, so Android is probably fine. Splash background is also dark-only (`:38`).
|
||||
- Fix hint: set `userInterfaceStyle: 'automatic'` and give the splash a light variant (`splash.dark`).
|
||||
|
||||
- [x] **Themes tab is a stub with fake themes; `activeThemeId` never applied** — fixed in fe45a44 — `P3` — `missing` (record as N/A for now) — Qui/Nord/Catppuccin/Solarized token sets generated from lib/builtin-themes.ts (src/theme/builtin-themes.ts); useColors() applies activeThemeId; upload button removed
|
||||
- What WEB does: 6 built-in themes (`lib/builtin-themes.ts:879-936`), zip upload, marketplace, admin forced/default theme, compiled CSS tokens, PWA theme-color meta (`stores/theme-store.ts`).
|
||||
- What RN does: `ThemesSettings.tsx:18-22` lists `Default/Qui/Sepia` (Qui exists in WEB; Sepia does not), stores `activeThemeId` that nothing reads, "Upload .zip" has no handler (`:81-83`); the tab is marked Experimental.
|
||||
- Fix hint: minimal viable subset = map WEB's built-in theme token sets (`builtin-themes.ts` light/dark `colors`) onto `ThemePalette` and let `useColors()` pick `activeThemeId`; remove the upload button. Otherwise hide the tab.
|
||||
|
||||
- [ ] **Status bar / navigation bar theming partial** — `P3` — `partial` — deferred: expo-navigation-bar / expo-system-ui are not installed; add one and call setBackgroundColorAsync(c.background) on theme change
|
||||
- What WEB does: theme-color meta follows active theme (#671).
|
||||
- What RN does: `StatusBar style` is derived (`App.tsx:219-223`) but the Android navigation bar (edge-to-edge is on, `app.config.js:56`) is never themed, and the bottom tab bar/`SafeAreaView` colours come from `useColors` so they are fine.
|
||||
- Fix hint: `expo-navigation-bar`/`SystemUI.setBackgroundColorAsync(c.background)` on theme change.
|
||||
|
||||
### Updates
|
||||
|
||||
- [x] **No severity concept; security/deprecated updates are dismissable** — fixed in 431face — `P3` — `partial` — severity:/advisory: lines in the release body
|
||||
- What WEB does: server-side check against `version.telemetry.bulwarkmail.org` with `severity` (normal/security/deprecated), red non-dismissable banner for security/deprecated, stale cached status discarded after upgrade (`lib/version-check/sender.ts:86-96`, `stores/update-store.ts:86-122`, commit 39dcc6b2).
|
||||
- What RN does: GitHub Releases `latest` only (`RN: src/api/updates.ts:57-91`); banner dismissed per tag (`UpdateBanner.tsx:21`, `updates-store.ts:136-146`); no notion of a mandatory update; no advisory link.
|
||||
- Fix hint: have the version server add a `native` channel (or read a `severity:` line from the release body) and make the banner non-dismissable when `security`/`deprecated`.
|
||||
|
||||
- [x] **Update check hits the GitHub API unauthenticated and blocks on companion downloads** — fixed in 431face — `P3` — `rn-only-bug`
|
||||
- What RN does: `fetchLatestRelease` calls `api.github.com` (60 req/h/IP) and then downloads the `.apk.sha256` asset on every check (`RN: src/api/updates.ts:44-55,74-81`); on rate-limit the store shows `GitHub API 403` under "Last checked" with no backoff other than the 6 h interval.
|
||||
- Fix hint: treat 403/429 as "skip until next interval" without surfacing an error, fetch the checksum only when the user taps Install.
|
||||
|
||||
- [x] **Release notes rendered raw** — fixed in 431face — `P3` — `partial`
|
||||
- What RN does: `cachedLatest.body` shown as plain text, 20 lines (`UpdatesSettings.tsx:187-194`); markdown headings/links appear literally.
|
||||
- Fix hint: strip markdown or use a tiny renderer; link to `htmlUrl`.
|
||||
|
||||
### Plugins, extensions, S/MIME, sidebar apps (mostly N/A / out of scope for now)
|
||||
|
||||
- [x] **Plugins tab is a non-functional stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now) — tab hidden; pane is an explainer
|
||||
- What WEB does: sandboxed iframe plugin runtime, marketplace, signing, admin approval, ~40 hooks/API methods (changelog 1.5.x-1.9.x Plugins entries).
|
||||
- What RN does: `PluginsSettings.tsx:41` starts with `plugins = []`, "Upload .zip" has no handler (`:176-178`), `pluginEnabled` map persists toggles for nothing.
|
||||
- Fix hint: out of scope; hide the tab (or keep it as an "install from webmail" explainer). Minimal viable subset would be theme-only plugins (token sets) once the Themes finding lands.
|
||||
|
||||
- [x] **S/MIME tab is a stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now)
|
||||
- What WEB does: S/MIME moved out of core into a privileged crypto plugin (changelog 1.6.0 "Breaking"); core exposes key/cert management on the Security tab.
|
||||
- What RN does: `SmimeSettings.tsx:32-33` uses empty `MOCK_KEYS`/`MOCK_CERTS`; Import buttons have no handlers (`:118-120,163-165`); three toggles persist unused prefs; the tab is labelled implemented and lives under Privacy.
|
||||
- Fix hint: mark `implemented: false` in `SettingsScreen.tsx:97` until a native crypto path exists.
|
||||
|
||||
- [ ] **Sidebar apps: settings exist, apps never rendered; no admin defaults (#931)** — `P3` — `partial` — deferred: useMobileSidebarApps()/openSidebarApp() in src/lib/sidebar-apps.ts (0e63d45) ready for SidebarDrawer (mail-list agent's file); admin defaults N/A
|
||||
- What WEB does: apps render in the navigation rail (`components/layout/navigation-rail.tsx:391` filters `showOnMobile`), inline iframe or new tab, `keepAppsLoaded`, drag reorder, plus operator-pinned apps merged from policy (`lib/sidebar-apps.ts:103-112`, commit 4fe5701b).
|
||||
- What RN does: `SidebarAppsSettings.tsx` adds/edits/removes entries into `sidebarApps` (`settings-store.ts:502-523`) but `SidebarDrawer.tsx` never reads them (grep: no usage outside settings); reorder handle is decorative (`:72`).
|
||||
- Fix hint: minimal viable subset = list `sidebarApps.filter(showOnMobile)` at the bottom of `SidebarDrawer`, open with `expo-web-browser` (`openMode:'tab'`) or a `react-native-webview` screen (`inline`); admin defaults are N/A without a policy source (could be read from a webmail `/api/config` if the webmail origin becomes known).
|
||||
|
||||
### Misc UI
|
||||
|
||||
- [x] **Deep-link scheme registered but nothing handles it** — fixed in 5802cae — `P2` — `missing` — bulwarkmobile://, webmail https permalinks, mailto: (scheme + SENDTO filter) and ACTION_SEND/SEND_MULTIPLE share targets → Compose; iOS universal links still deferred
|
||||
- What WEB does: permalinks for mail/calendar/contacts/files/settings with build/parse helpers (`lib/deep-links.ts:196-235,272-311,322-370,379-414`), `mailto:` opens the built-in composer, protocol handler registration, `webcal:`.
|
||||
- What RN does: `scheme: 'bulwarkmobile'` (`RN: app.config.js:29`) and a `VIEW`/`BROWSABLE` intent filter (`AndroidManifest.xml:25-30`) exist, but `NavigationContainer` has no `linking` prop (`App.tsx:443`) and there is no `Linking.getInitialURL`/`addEventListener('url')` anywhere, so tapping a `bulwarkmobile://` URL merely launches the app. No `https` App Links for the webmail permalink format, no `mailto:` intent filter (tapping a mailto link in another app never offers Bulwark), no `SEND`/`SEND_MULTIPLE` share target.
|
||||
- Fix hint: add a `linking` config mapping `bulwarkmobile://mail/message/:emailId`, `/calendar/event/:id`, `/contacts/:id`, `/settings/:tab` to the existing routes (`src/navigation/types.ts`), reuse WEB's path grammar so a webmail permalink can be rewritten; add `<data android:scheme="mailto"/>` and `ACTION_SEND` (`text/*`, `image/*`) filters routed to `Compose` with `prefillTo`/attachments. iOS: `CFBundleURLTypes` via `scheme` is already emitted; add `associatedDomains` for universal links later.
|
||||
|
||||
- [x] **No general toast system; only the email undo snackbar** — fixed in 3ae9bbe — `P3` — `partial` — src/stores/toast-store.ts + ToastHost mounted in App.tsx; Alert.alert call sites not migrated
|
||||
- What WEB does: typed toasts with title/message, primary+secondary actions, progress bar, pause-on-hover, 10 s for errors (`stores/toast-store.ts`, `components/ui/toast.tsx`), used for every post-action acknowledgement (move toast shows full folder path, send-delay toast with "Send now").
|
||||
- What RN does: `UndoSnackbar.tsx` is bound to `useEmailStore.pendingUndo` only; 23 files use `Alert.alert` (modal, blocking) for confirmations and errors; success acknowledgements mostly absent.
|
||||
- Fix hint: generalise `UndoSnackbar` into a `toast-store` (queue, type, action) and mount one host in `App.tsx`; migrate error `Alert.alert`s that need no decision.
|
||||
|
||||
- [x] **No prompt dialog; Dialog is confirm-only and untranslated** — fixed in 3ae9bbe — `P3` — `partial`
|
||||
- What WEB does: `components/ui/confirm-dialog.tsx` and `prompt-dialog.tsx` (text input), `useConfirmDialog` hook.
|
||||
- What RN does: `Dialog.tsx:8-17` takes title/message/confirm/cancel with English defaults `'Confirm'`/`'Cancel'` (`:33-34`); text prompts are ad-hoc `TextInput`s.
|
||||
- Fix hint: add an `input` prop and default the labels via `t('common.confirm')`/`t('common.cancel')`.
|
||||
|
||||
- [x] **Offline banner not localized; no "reconnect" retry surface** — fixed in 990cd84 — `P3` — `partial`
|
||||
- What WEB does: `online` event triggers refetch (`components/mail/mail-app.tsx:269-278`, `lib/jmap/client.ts:7398`); strings come from catalogs.
|
||||
- What RN does: `OfflineBanner.tsx:22-29` hard-codes "You are offline" with an English plural; network-store logic itself is sound (`network-store.ts:16-22` treats `isInternetReachable === null` as online) and App.tsx retries the session on reconnect (`:245-252`).
|
||||
- Fix hint: `t('common.offline', ...)` and reuse plural helper once interpolation exists.
|
||||
|
||||
- [ ] **Accessibility labels almost absent** — `P3` — `partial` — done in my files (RadioGroup/Select roles, Dialog header, Settings back button, ToastHost, UpdateBanner, AccountSettings, security key rows); other areas' screens untouched
|
||||
- What WEB does: aria-labels on icon buttons, `role="checkbox"`, screen-reader improvements (changelog 1.8.0 Navigation).
|
||||
- What RN does: `accessibilityLabel`/`Role` appear in 8 of 94 `.tsx` files (FilesScreen 4, EmailListScreen 3, login 4, EmailThreadScreen 2, ToggleSwitch 1). Icon-only `Pressable`s in settings (`SettingsScreen.tsx:217-222` back button, `SidebarAppsSettings.tsx:91-96`, `SmimeSettings.tsx:91-106`, `UpdateBanner.tsx:40`), the tab bar badge and the `RadioGroup`/`Select` in `settings-section.tsx` have none.
|
||||
- Fix hint: give every icon-only Pressable an `accessibilityLabel` + `accessibilityRole="button"`, `ToggleSwitch` `accessibilityRole="switch"` + `accessibilityState`, `RadioGroup` options `radio`.
|
||||
|
||||
- [x] **No haptic feedback** — fixed in 3ae9bbe — `P3` — `missing` (mobile-only nicety) — expo-haptics + src/lib/haptics.ts haptic(kind); SwipeableRow/undo wiring is the mail-list agent's (call haptic('light') at threshold, haptic('success') on undo)
|
||||
- What RN does: `VIBRATE` permission exists only for the notification channel; no `expo-haptics` in `package.json`; swipe-to-action, undo and destructive confirms give no tactile feedback.
|
||||
- Fix hint: `expo-haptics` `impactAsync(Light)` on swipe threshold cross and `notificationAsync(Success/Warning)` on undo/destructive.
|
||||
|
||||
- [x] **Version badge / "update available" tag missing from About** — fixed in 8deff66 — `P3` — `partial`
|
||||
- What WEB does: About card shows version, commit and an `update: x.y.z` / `security` pill (`components/settings/about-data-settings.tsx:20-49`).
|
||||
- What RN does: `AboutDataSettings.tsx:114-134` shows version+commit; update info lives only in the (Android-only) Updates tab; on iOS nothing indicates a newer build exists.
|
||||
- Fix hint: reuse `useUpdatesStore.hasUpdate()` for a pill; on iOS link to TestFlight/App Store instead of Install.
|
||||
|
||||
## Verified at parity (do not redo)
|
||||
|
||||
- Per-account push registry keys, legacy key migration, in-flight coalescing, relay register/verify/active/unregister endpoint usage, 90-day expiry with 7-day refresh, relay-confirmed-dead reaping: `RN: src/lib/push-notifications.ts:16-95,271-298,342-383,411-439` match `lib/web-push.ts:16-25,334-389,448-489` and `repos/relay/README.md` endpoints.
|
||||
- Notification tap routing switches account before navigating (`RN: App.tsx:58-78`), cold-start tap via `NotificationTapStore`.
|
||||
- Headless task respects `emailNotificationsEnabled` and restores the active account afterwards (`push-background-task.ts:32-41,136-144`).
|
||||
- Relay treats `EmailPush` bodies and forwards only ids (`repos/relay/src/payload.ts`); RN register payload shape (`subscriptionId`, `fcmToken`, `accountLabel`) matches `handleRegister`.
|
||||
- Update cache staleness after upgrade (#913 analogue): RN compares `cachedLatest.tag` to the running version at read time (`updates-store.ts:150-156`), so a cached "newer" release cannot survive an upgrade. `version-compare.ts` semantics match WEB `lib/version-compare.ts`.
|
||||
- APK download: size check, optional SHA-256 from companion asset or body, `REQUEST_INSTALL_PACKAGES`, installer intent (`install-update.ts`); Updates tab hidden off Android (`platform-capabilities.ts`, `SettingsScreen.tsx:145-147`); pending-APK polling every 30 s.
|
||||
- Theme light/dark/system palette hook (`src/theme/colors.ts`), density/font hooks exist (`dynamic.ts`), status bar style follows theme (`App.tsx:219-223`).
|
||||
- Language picker with system default, `dateFormat` preview, `timeFormat` (`LanguageSettings.tsx`); locale override persisted (`locale-store.ts`); `t()` falls back en → fallback → key.
|
||||
- Confirm dialog styling mirrors WEB confirm-dialog (`Dialog.tsx:19-27`); undo snackbar timer is `createdAt`-based so re-renders do not reset it.
|
||||
- Offline detection via NetInfo with reachability fallback; outbox flush on reconnect (`App.tsx:289-295`).
|
||||
- Hardware back closes a settings pane (`SettingsScreen.tsx:192-199`); settings groups/tabs mirror WEB's six groups.
|
||||
|
||||
## N/A on mobile
|
||||
|
||||
- Pro interface, toolbar position, hide account switcher / rail account list, hover actions & corners, favicon unread badge, keyboard shortcuts (`hooks/use-keyboard-shortcuts.ts`), guided tour / onboarding-on-new-devices, PWA install prompt & Apple touch icons, protocol handler page (`mailto:`/`webcal:` registration is done via intent filters instead — see deep-link finding), `protocolOpenMode`, admin policy lock/hide of settings, plugin sandbox/marketplace/signing, theme zip upload, telemetry (server-side), server-side version-check scheduler, `settingsSyncEnabled` config flag, `NEXT_PUBLIC_LOCALE_PREFIX` routing, calendar hover preview, mail layout (split/focus/horizontal), resizable columns (`stores/ui-store.ts`), `SETTINGS_DATA_DIR`/`SESSION_SECRET` encryption at rest (native has no server; use OS keystore semantics instead), spam-siege easter egg.
|
||||
@@ -0,0 +1,319 @@
|
||||
# JMAP client core, live sync, offline/outbox, network & error handling, security, S/MIME & encryption-at-rest
|
||||
|
||||
## Summary
|
||||
RN's JMAP client (`src/api/jmap-client.ts`, 615 lines) is a thin transport: session discovery, Basic/Bearer auth with proactive+reactive OAuth refresh, 401/429 mapping, capability lookups, blob URL templating. It lacks essentially every robustness layer WEB grew between 1.4.5 and 1.9.2: request deadlines (#702), `maxConcurrentRequests` back-off (#780), transient-network retry, rate-limit pause/resume, `maxObjectsInSet`/`maxSizeUpload` awareness, relative-session-URL resolution, keep-alive ping with connection-lost signalling, and first-touch serialisation (#907). Most RN API functions index `methodResponses[0][1]` without checking for `error` / `notUpdated`, which combines badly with the outbox (a rejected mutation is treated as done). Live sync is the weakest area: there is no `AppState` handling at all, `react-native-sse` never re-polls after a network error (status 0), the SSE stream captures the Authorization header once (stale after OAuth refresh), the push effect does not re-run on account switch (stream stays bound to the previous account), and a client-cert user gets no SSE and no polling fallback. Security posture is decent (SecureStore, hardened WebView, no secret logging) but has gaps: `Linking.openURL` with unvalidated schemes from contact/calendar data, `Math.random` for OAuth `state` and TOTP secret, http:// server URLs accepted, AsyncStorage mail cache in Android auto-backup, password change leaving stored credentials stale (locks the account out). S/MIME is a mock UI; encryption-at-rest is read-only; List-Unsubscribe is absent.
|
||||
|
||||
## Findings
|
||||
|
||||
### JMAP client core (request pipeline)
|
||||
|
||||
- [x] **No request deadline: a stalled socket hangs send/save forever (#702)** — `P1` — `bugfix-parity` — fixed in 0b1c240
|
||||
- What WEB does: `timedFetch` puts a 30 s deadline on response headers (300 s for blob transfers), throws `RequestTimeoutError`, never retries a timed-out non-idempotent request (`lib/jmap/client.ts:629-634`, `786-816`, `836-846`; changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)").
|
||||
- What RN does: `request()`, `fetchSession()`, `fetchBlobArrayBuffer()` call `secureFetch` with no `AbortController`/timeout (`src/api/jmap-client.ts:405-447`, `363-386`, `568-584`). RN `fetch` has no timeout of its own; iOS suspends sockets in background and hands back dead pooled connections on resume, so the composer's Send stays disabled indefinitely. Only the client-cert native path has a 30 s timeout (`src/lib/client-cert.ts:250`).
|
||||
- Fix hint: wrap every fetch in `jmap-client.ts` with an `AbortController` + `setTimeout` (30 s API, 300 s blob), throw a `RequestTimeoutError` subclass, and make `isTransientNetworkError` classify it as transient only for idempotent ops (never re-send `EmailSubmission/set`).
|
||||
|
||||
- [x] **`maxConcurrentRequests` refusal (400 `jmap:error:limit`) is a hard failure (#780)** — `P2` — `bugfix-parity` — fixed in 0b1c240
|
||||
- What WEB does: `isConcurrentRequestRefusal` + 3 jittered retries (200/400/800 ms) before failing (`lib/jmap/client.ts:462-473`, `1136-1146`; changelog "Keep the folder tree when a refresh burst hits maxConcurrentRequests (#780)").
|
||||
- What RN does: any non-OK status throws `JMAP request failed: 400` (`src/api/jmap-client.ts:442-444`). A single StateChange fans out to `handleStateChange` in email, contacts and calendar stores concurrently (`App.tsx:391-396`), each of which issues 1-3 requests, so Stalwart's default ceiling of 4 is hit in ordinary use; the folder tree is preserved (`src/stores/email-store.ts:604-612`) but the refresh is dropped.
|
||||
- Fix hint: port `isConcurrentRequestRefusal` and the retry loop into `JMAPClient.request()`; read the body text before deciding.
|
||||
|
||||
- [x] **No transient-network retry** — `P2` — `missing` — fixed in 0b1c240
|
||||
- What WEB does: retries once after 1 s on a fetch `TypeError` unless already reconnecting or timed out (`lib/jmap/client.ts:836-846`).
|
||||
- What RN does: `request()` propagates the raw `TypeError("Network request failed")` (`src/api/jmap-client.ts:427`); `isTransientNetworkError` recognises it (`src/lib/network-error.ts:48-63`) but only the outbox uses that.
|
||||
- Fix hint: single retry for GET/idempotent bodies in `request()`; keep non-idempotent (`EmailSubmission/set`) un-retried.
|
||||
|
||||
- [x] **429 handling: no rate-limit state, no pause of live updates, HTTP-date `Retry-After` becomes NaN** — `P2` — `partial` — fixed in 0b1c240 (rate-limit window + `onRateLimit`; SSE/polling pause via the window check in `request()`)
|
||||
- What WEB does: `setRateLimited` blocks all requests until the window ends, tears down and re-arms push, notifies UI via `onRateLimit` and a throttled toast event; `parseRetryAfter` handles seconds and HTTP-date, caps at 5 min, defaults 60 s (`lib/jmap/client.ts:820-826`, `848-852`, `7421-7486`; `stores/auth-store.ts:208-226`).
|
||||
- What RN does: throws `RateLimitError(ms)` (`src/api/jmap-client.ts:436-440`) where `parseInt` of an HTTP-date yields `NaN*1000`; nobody catches it (grep: only tests). SSE reconnect (5 s), polling (5 s), outbox flush and stores keep hammering the server during the window.
|
||||
- Fix hint: add `rateLimitedUntil` to the client, short-circuit `request()` while limited, close SSE/polling and re-arm after the window, expose to `OfflineBanner`.
|
||||
|
||||
- [x] **HTTP error body and cause not surfaced; JSON parse unguarded** — `P3` — `partial` — fixed in 0b1c240
|
||||
- What WEB does: error message includes status + first 200 chars of body, JSON parse failure mapped to a clear error (`lib/jmap/client.ts:1147-1156`; changelog "Surface the underlying network error cause in passthrough failures").
|
||||
- What RN does: `JMAP request failed: ${status}` and `response.json()` unguarded (`src/api/jmap-client.ts:442-446`); `loadAccount` wraps into `NetworkError` but `request()` throws bare `TypeError`.
|
||||
- Fix hint: read text, include in message, wrap `JSON.parse`; throw `NetworkError` from `request()` too.
|
||||
|
||||
- [x] **Method-level `error` / `notUpdated` / `notDestroyed` unchecked in most RN API functions** — `P1` — `rn-only-bug` — fixed in ccbe67c (mail/identity/push; contacts, calendar, files done by their agents)
|
||||
- What WEB does: mutations inspect `notUpdated`/`notDestroyed` (`lib/jmap/client.ts:2282-2286` moveEmail; `3290-3345` sendEmail), reads check the method name before indexing (`1207-1208`).
|
||||
- What RN does: `res.methodResponses[0][1].list` / `.ids` indexed blindly in `getMailboxesWithState` (`src/api/email.ts:61-70`), `getEmails` (`322-327`), `getEmailsWithState` (`338-343`), `getThread` (`428-434`), `queryEmails` (`244-260`), `getIdentities` (`src/api/identity.ts:5-12`); `Email/set` mutations never look at the response: `setEmailKeywords` (`email.ts:436-445`), `moveEmail`/`moveEmails` (`447-480`), `deleteEmails` (`483-495`), `setKeywordsForEmails` (`527-539`), `restoreEmailMailboxes`/`setEmailMailboxes` (`541-575`), `destroyEmails` (`581-586`), `updateIdentity`/`deleteIdentity` (`identity.ts:27-45`). An `['error', {type:'forbidden'}]` response makes the read path throw a `TypeError` (classified transient by `network-error.ts:51`, see outbox finding) and makes the write path report success: the outbox removes the entry (`src/stores/outbox-store.ts:199-200`), the optimistic list/cache edit is never reverted.
|
||||
- Fix hint: add a shared `requireMethodResult(res, callId)` (like `resultFor` in `src/api/account-security.ts:59-67`) and an `assertSetResult(body, ids)` that throws on `notUpdated`/`notDestroyed`; use them in every `src/api/*.ts` function.
|
||||
|
||||
- [x] **`Email/get` and `Email/set` not split to `maxObjectsInGet`/`maxObjectsInSet`; no `getMaxObjectsInSet`** — `P2` — `bugfix-parity` — fixed in ccbe67c
|
||||
- What WEB does: `batched(ids, getMaxObjectsInGet())` for gets (`lib/jmap/client.ts:713-727`), `getMaxObjectsInSet()` for `batchMarkAsRead`, `batchDeleteEmails`, `batchMoveEmails`, `batchUpdateKeywords` (`1885-1894`, `2109-2136`, `1953-1961`, `4194-4197`; `lib/jmap/request-limits.ts`; changelog "Split requests to stay inside the server's advertised limits").
|
||||
- What RN does: `getEmailsWithState` (`src/api/email.ts:338-343`) is called from the incremental refresh with an unbounded `addedIds + updatedIds` list (`src/stores/email-store.ts:848-857`), `getMailboxesByIds` after `Mailbox/changes` is unbounded (`email.ts:114-127`, store `565-567`), `listScheduledEmails` fetches up to 200 ids in one get (`email.ts:922-928`); `setKeywordsForEmails`, `moveEmails`, `restoreEmailMailboxes`, `destroyEmails`, `archiveEmails` put every selected id into one `Email/set` (no `maxObjectsInSet` accessor exists in `jmap-client.ts`). Only `fetchEmailsChunked` (`email-store.ts:371-381`), `getEvents`/`getContacts` (`src/api/calendar.ts:214`, `contacts.ts:45`) and `offline-sync.ts:92` chunk. Over-limit requests fail whole, so a long offline gap (many updated ids) breaks the incremental refresh until the snapshot is dropped.
|
||||
- Fix hint: add `getMaxObjectsInSet()` + a `batched()` helper to `jmap-client.ts`; apply in the listed functions.
|
||||
|
||||
- [x] **`maxSizeUpload` / `maxSizeRequest` ignored** — `P3` — `missing` — fixed in 0b1c240 (accessors; enforced by the files/composer agents)
|
||||
- What WEB does: `getMaxSizeUpload()` (`lib/jmap/client.ts:4179-4182`) gates attachment/file uploads with a clear message (changelog 1.6.x "Use dynamic server-configured maximum upload sizes"; "maxSizeRequest").
|
||||
- What RN does: no reference to `maxSizeUpload` anywhere (`grep -rn maxSizeUpload src` empty); `uploadBlob` (`src/api/blob.ts:5-62`) surfaces an opaque `Upload failed: 413`.
|
||||
- Fix hint: expose `getMaxSizeUpload()` and check before `File(uri).bytes()` in `blob.ts`/ComposeScreen.
|
||||
|
||||
- [x] **Relative session URLs are not resolved (37f830da)** — `P2` — `bugfix-parity` — fixed in 0b1c240 (unified-inbox.ts with the mail-list agent)
|
||||
- What WEB does: `rewriteSessionUrl` rebases both absolute and relative (`/jmap/`) `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` onto the login origin without touching `{accountId}` templates (`lib/jmap/client.ts:1091-1114`; changelog 1.9.x "Resolve relative session URLs without corrupting URI templates").
|
||||
- What RN does: `rewriteSessionUrls` only rewrites when `extractOrigin(url)` matches `^https?://` (`src/api/jmap-client.ts:320-340`); a relative `apiUrl` is returned unchanged and `fetch('/jmap/')` then fails with "Network request failed". Same gap in `src/api/unified-inbox.ts:55-60`. (RN correctly avoids `new URL()` so templates are not corrupted.)
|
||||
- Fix hint: in `rewrite()`, when `extractOrigin` is null and the string starts with `/`, return `serverOrigin + url`.
|
||||
|
||||
- [x] **Capability check uses the broad `principals` URN, not `principals:owner` (44360024)** — `P2` — `bugfix-parity` — fixed in a0ce925
|
||||
- What WEB does: declares `urn:ietf:params:jmap:principals:owner` in `using` only when that exact capability is advertised in session or account capabilities (`lib/jmap/client.ts:4689-4698`, `6472`; changelog "Check the specific capability a request declares (principals:owner), not a broader one").
|
||||
- What RN does: `supportsSharing()` checks `CAPABILITIES.PRINCIPALS` (`src/api/files.ts:23-25`) and then pushes `PRINCIPALS_OWNER` into `using` (`files.ts:40-43`). A server advertising `principals` but not `principals:owner` rejects every FileNode request with `unknownCapability`.
|
||||
- Fix hint: check `hasCapability(CAPABILITIES.PRINCIPALS_OWNER) || accountCapabilities[...]` before adding it.
|
||||
|
||||
- [x] **PatchObject pointer keys not JSON-Pointer escaped (5c484af1)** — `P3` — `bugfix-parity` — fixed in ccbe67c
|
||||
- What WEB does: `keywordPointer()`/`pointerToken()` escape `~` and `/` (`lib/jmap/patch-pointer.ts:39-55`); mailbox moves use a whole-map replacement (`lib/jmap/client.ts:66-74`).
|
||||
- What RN does: `moveEmail`/`moveEmails` build `mailboxIds/${id}` pointers unescaped (`src/api/email.ts:456-457`, `473-474`). Stalwart ids never contain `/`, so exposure is limited to non-Stalwart servers; keywords are always sent as a whole map (`email.ts:436-445`), so tag names with `/` are safe.
|
||||
- Fix hint: add `pointerToken()` or switch `moveEmail(s)` to the full `mailboxIds` replacement RN already uses in `restoreEmailMailboxes`.
|
||||
|
||||
- [x] **Send: message created directly in Sent instead of Drafts + `onSuccessUpdateEmail` (#188)** — `P2` — `bugfix-parity` — fixed in ccbe67c
|
||||
- What WEB does: creates in Drafts with `$draft`, submits, and moves to Sent via `onSuccessUpdateEmail` (full `mailboxIds` replacement, `keywords/$draft: null`) so the SMTP send happens before the message lands in Sent; needed for servers that encrypt on append (`lib/jmap/client.ts:3186-3190`, `3231-3238`, `3272-3287`; changelog "File the post-send message with a full mailboxIds replacement").
|
||||
- What RN does: `emailCreate.mailboxIds = { [sentMailboxId]: true }`, `keywords: { $seen: true }`, no `onSuccessUpdateEmail` (`src/api/email.ts:786-793`, `836-846`). With Stalwart encryption-at-rest + `encryptOnAppend` the copy is encrypted before `EmailSubmission/set` reads it; and if the submission fails, a never-sent copy sits in Sent.
|
||||
- Fix hint: mirror WEB: create in Drafts (needs the drafts mailbox id), `onSuccessUpdateEmail: { '#draft': { mailboxIds: {sent: true}, 'keywords/$draft': null } }`, and report `notUpdated` as a filing warning.
|
||||
|
||||
- [x] **Message-ID / In-Reply-To handling diverges from WEB** — `P3` — `bugfix-parity` — fixed in ccbe67c
|
||||
- What WEB does: generates `messageId` client-side from the sender domain, strips angle brackets from `inReplyTo`/`references` and sends them as JMAP arrays (`lib/jmap/client.ts:546-570`, `3177-3180`, `3186-3188`; changelog "Generate the Message-ID client-side using the sender's domain").
|
||||
- What RN does: relies on the server for Message-ID; uses raw `header:In-Reply-To:asText` / `header:References:asText` strings (`src/api/email.ts:823-826`).
|
||||
- Fix hint: port `generateMessageId` + `stripMessageIdBrackets`, use `inReplyTo`/`references` arrays.
|
||||
|
||||
- [x] **Dead duplicate `src/api/submission.ts` with zero error checking** — `P3` — `rn-only-bug` — fixed in 42ab246
|
||||
- What RN does: `submission.ts:140-199` is an older `sendEmail` that ignores `notCreated`/errors; no importers (`grep api/submission` empty). Risk is someone wiring it back in.
|
||||
- Fix hint: delete the file.
|
||||
|
||||
- [x] **No keep-alive ping / connection-lost signalling / session refresh** — `P2` — `missing` — fixed in edc26ce
|
||||
- What WEB does: `Core/echo` every 30 s with exponential skip back-off, `reconnect()` on failure, `onConnectionChange` drives the "connection lost" banner and per-account `isConnected` (`lib/jmap/client.ts:1010-1075`; `stores/auth-store.ts:199-206`); basic-auth 401 re-fetches the session and re-prompts TOTP (`client.ts:862-897`).
|
||||
- What RN does: no ping (`grep Core/echo src` empty); connectivity comes only from NetInfo (`src/stores/network-store.ts`), so a reachable-but-down server never surfaces, and the session document (capabilities, shared accounts, `eventSourceUrl`) is never re-fetched until restart. A basic-auth 401 throws `AuthenticationError` straight away (`src/api/jmap-client.ts:432-434`).
|
||||
- Fix hint: add a 30 s `Core/echo` while foregrounded (see AppState finding) feeding `network-store.online`/`account-store.isConnected`; on 401 for basic auth re-fetch the session once before failing.
|
||||
|
||||
- [x] **Password change leaves the stored credential stale, then evicts the account** — `P1` — `rn-only-bug` — fixed in 032732c
|
||||
- What WEB does: (same class of exposure via cookie, but the server-side passthrough re-reads; out of scope). `stores/account-security-store.ts:503-526`.
|
||||
- What RN does: `changePassword` succeeds (`src/api/account-security.ts:185-197`, `src/components/settings/AccountSecuritySettings.tsx:137-141`) but `JMAPClient.credentials.password` and the SecureStore entry keep the old password. The next request gets 401 -> `AuthenticationError`; on the next launch `restoreSession` clears credentials and removes the account (`src/stores/auth-store.ts:516-523`), and `retrySession` does the same (`571-587`). Push background task also starts failing.
|
||||
- Fix hint: add `jmapClient.updatePassword(newPassword)` that rewrites `credentials` and persists via `setStoredCredentials`; call it after a successful `changePassword` (and after TOTP enable/disable if the password format changes).
|
||||
|
||||
- [x] **First-touch serialisation for Calendar/Contacts (#907)** — `P3` — `bugfix-parity` — fixed in 0b1c240
|
||||
- What WEB does: `FirstTouchGate` holds later Calendar*/AddressBook*/ContactCard* requests per account until the first one settles, so clustered Stalwart doesn't create two default calendars (`lib/jmap/first-touch-gate.ts`, `lib/jmap/client.ts:1121-1128`).
|
||||
- What RN does: `refetchFeatureStores` fires `fetchContacts`, `fetchCalendars`, `refresh` concurrently on every login/restore/switch (`src/stores/auth-store.ts:81-99`).
|
||||
- Fix hint: port `first-touch-gate.ts` and wrap `request()`; reset it in `reset()`/`loadAccount`.
|
||||
|
||||
- [x] **Mailbox fetch not retried on first login (lazy provisioning, #217)** — `P3` — `bugfix-parity` — fixed in 3194b5e
|
||||
- What WEB does: retries the initial mailbox fetch when a freshly created account returns no folders (changelog 1.6.x "Retry mailbox fetch on first login to handle lazy provisioning (#217)").
|
||||
- What RN does: `fetchMailboxes` does one `Mailbox/get`; an empty list on a brand-new Stalwart account stays empty until pull-to-refresh (`src/stores/email-store.ts:584-588`).
|
||||
- Fix hint: if the list is empty and there is no `inbox` role, retry after ~1 s up to 3 times.
|
||||
|
||||
- [x] **Session redirect with dropped Authorization not handled** — `P3` — `partial` — fixed in 0b1c240
|
||||
- What WEB does: detects `response.redirected` + empty accounts/username and refetches the final URL with the header (`lib/jmap/client.ts:911-925`).
|
||||
- What RN does: `fetchSession` accepts whatever comes back; `resolveAccountId` then throws "No account found in JMAP session" (`src/api/jmap-client.ts:363-401`). iOS strips Authorization on cross-origin redirects.
|
||||
- Fix hint: same detection; retry against `response.url`.
|
||||
|
||||
### Live updates (SSE / polling / state changes)
|
||||
|
||||
- [x] **No `AppState` handling: SSE never paused in background nor recovered on foreground; no refresh on resume** — `P1` — `missing` — fixed in edc26ce
|
||||
- What WEB does: `visibilitychange` -> immediate `checkForStateChanges()` + `recycleStaleSSE()`; `online` -> reconnect SSE or poll; polls pause while hidden; 90 s ping-timeout monitor (`lib/jmap/client.ts:7332-7400`, `6992-6996`; changelog #702/#781 notes).
|
||||
- What RN does: `grep -rn AppState src App.tsx` is empty. The EventSource is opened once (`App.tsx:380-418`, `src/api/push.ts:120-157`). `react-native-sse` re-polls only when the XHR reaches DONE with a status (`node_modules/react-native-sse/src/EventSource.js:118-131`); on `onerror`/status 0 (socket dropped, iOS suspension, Wi-Fi to cellular) nothing schedules a reconnect (`137-149`). Result: after the first network blip, push is dead for the rest of the process lifetime with no visible sign; the app also keeps the socket (and 30 s server pings) alive while backgrounded.
|
||||
- Fix hint: `AppState.addEventListener('change')`: on `background` close the EventSource; on `active` recreate it and call `fetchMailboxes()`/`refreshEmails()`/outbox `flush()`; also subscribe to `useNetworkStore` `online` transitions to recreate. Listen for the SSE `error` event and schedule a reconnect with back-off.
|
||||
|
||||
- [x] **SSE Authorization header captured once; stale after OAuth token refresh** — `P1` — `rn-only-bug` — fixed in edc26ce
|
||||
- What WEB does: `authenticatedFetch` reads `this.authHeader` on every (re)connect, and `onTokenRefresh` retries a 401 SSE connect (`lib/jmap/client.ts:7049-7053`, `855-863`).
|
||||
- What RN does: `connectEventSource` passes `{ headers: { Authorization: jmapClient.authHeader } }` at construction (`src/api/push.ts:136-138`); `react-native-sse` reuses `this.headers` on every reconnect (`EventSource.js:83-87`). After the access token expires (`ensureFreshToken` rotates it for normal requests) every SSE reconnect sends the old Bearer -> 401 -> `error` -> re-poll every 5 s forever (battery drain, server log spam, no push).
|
||||
- Fix hint: on `error` with `xhrStatus === 401` close and recreate with the current `authHeader`; better, recreate the EventSource whenever `persistRefreshedTokens` runs.
|
||||
|
||||
- [x] **Push effect keyed on the singleton `client`: SSE stays bound to the previous account after `switchAccount`** — `P1` — `rn-only-bug` — fixed in edc26ce
|
||||
- What WEB does: per-account clients; the push effect depends on `activeAccountId` and the connected-accounts signature, tearing down and re-creating streams on switch (`components/mail/mail-app.tsx:1220-1263`).
|
||||
- What RN does: the effect deps are `[client, isAuthenticated]` (`App.tsx:418`); `client` is always the same `jmapClient` object (`src/stores/auth-store.ts:155`), and `isAuthenticated` stays true across `switchAccount` (`auth-store.ts:360-432`), so the effect never re-runs. The open EventSource keeps the old account's `eventSourceUrl` + credentials; its events are then dropped by `jmapClientServesActiveAccount` (`src/stores/email-store.ts:986`), and the new account gets no live updates until logout/login. The FCM setup effect already includes `activeAccountId` (`App.tsx:378`).
|
||||
- Fix hint: add `activeAccountId` (and `haveLiveSession`) to the push effect deps.
|
||||
|
||||
- [x] **No SSE failure/fallback path (server 4xx/5xx, proxy without SSE, client cert)** — `P2` — `partial` — fixed in edc26ce
|
||||
- What WEB does: non-OK SSE response -> `fallbackToPolling`; stream end -> reconnect after 3 s (rate-limit aware); `closeafter/ping` template substitution (`lib/jmap/client.ts:7049-7066`, `7129-7155`).
|
||||
- What RN does: `startPolling` is used only when `eventSourceUrl` is absent (`src/api/push.ts:228-230`); no `error`/`open` listeners on the EventSource. A user with a TLS client certificate (native issue #3) is worst hit: `react-native-sse` is XHR-based and cannot present the KeyChain cert (`src/lib/client-cert.ts:40-43` explains why `fetchSecure` exists), so the SSE handshake is rejected and no polling starts -> no live updates at all.
|
||||
- Fix hint: in `connectEventSource`, if `await getClientCertAlias()` is non-null skip SSE and return `startPolling(...)`; add an `error` handler that, after N consecutive non-401 failures, closes the source and falls back to polling.
|
||||
|
||||
- [x] **StateChange coverage: EmailSubmission, SieveScript, Identity, VacationResponse, FileNode ignored** — `P2` — `partial` — fixed in a071d05 (Sieve/vacation refetch; EmailSubmission/FileNode via `state-change-bus`)
|
||||
- What WEB does: handles `Email`, `EmailSubmission` (scheduled list/metadata), `Mailbox` (any account), `Calendar`/`CalendarEvent` (+tasks), `SieveScript` (filters) (`stores/email-store.ts:3242-3310`).
|
||||
- What RN does: `Email`/`EmailDelivery`/`Mailbox` (`src/stores/email-store.ts:981-1020`), `AddressBook`/`ContactCard`/`Contact` (`src/stores/contacts-store.ts:152-168`), `Calendar`/`CalendarEvent` incl. shared accounts (`src/stores/calendar-store.ts:261-283`). `ScheduledScreen`, `filter-store`, `vacation-store`, Files and Identity never refresh on push (`grep handleStateChange src/stores/filter-store.ts src/stores/vacation-store.ts` empty).
|
||||
- Fix hint: add `EmailSubmission` -> scheduled list refresh (when the Scheduled screen is mounted), `SieveScript` -> `filter-store.fetch`, `VacationResponse` -> vacation store, `FileNode` -> Files screen refresh; dispatch from the `onStateChange` fan-out in `App.tsx:391-396`.
|
||||
|
||||
- [ ] **Contacts state changes ignore shared/group accounts** — `P3` — `partial`
|
||||
- What WEB does: per-account clients; `handleStateChange` reacts to the client's own account (`stores/email-store.ts:3255-3257`); calendar/contacts stores use the account of the shared collection.
|
||||
- What RN does: `contacts-store.handleStateChange` only reads `change.changed[jmapClient.accountId]` (`src/stores/contacts-store.ts:154-156`) although address books from shared accounts are shown; `calendar-store` already folds shared accounts in (`calendar-store.ts:263-272`).
|
||||
- Fix hint: mirror the calendar store's `known` set using `addressBook.accountId`.
|
||||
|
||||
- [x] **Polling fallback is coarse: primary account only, fixed 5 s, no in-flight coalescing, no pause in background** — `P3` — `partial` — fixed in edc26ce
|
||||
- What WEB does: polls every session account plus Calendar/CalendarEvent/SieveScript, 3 s active / 20 s slow, skips when `document.hidden`, coalesces overlapping polls (#781) (`lib/jmap/client.ts:6948-6954`, `7157-7301`).
|
||||
- What RN does: `startPolling` polls `Mailbox/get` + `Email/get` for the primary account every 5 s with `setInterval`, no in-flight guard, never paused (`src/api/push.ts:163-202`); shared accounts, calendar, contacts and sieve never change under polling.
|
||||
- Fix hint: reuse the WEB request shape (`mbx:<id>`/`eml:<id>` call ids), add an in-flight flag and AppState pause, 20 s cadence in fallback.
|
||||
|
||||
- [x] **SSE stream keeps the whole response in memory; no periodic recycle** — `P3` — `rn-only-bug` — fixed in edc26ce
|
||||
- What WEB does: reads the body as a stream and discards processed chunks; recycles a silent stream after 90 s (`lib/jmap/client.ts:7070-7105`, `7332-7373`).
|
||||
- What RN does: `react-native-sse` accumulates `xhr.responseText` for the life of the connection (`EventSource.js:108`, `_lastIndexProcessed`), and RN requests `{ping}=30`, `{closeafter}=no` (`src/api/push.ts:126-129`), so memory grows for every ping/event until reconnect. No ping-timeout detection either.
|
||||
- Fix hint: either request `closeafter=state` (server closes after each event; library auto-reconnects) or recycle the EventSource every ~30 min / when no `ping` event arrived in 90 s (add a `ping` listener).
|
||||
|
||||
- [x] **Push subscription `types` include `Email` and `Mailbox`; no `emailPush` spam filter** — `P2` — `partial` — fixed in 00faceb
|
||||
- What WEB does: subscribes to `EmailDelivery` only, plus an `emailPush` filter excluding `$junk`/Junk when the server advertises `urn:ietf:params:jmap:emailpush` (`lib/web-push.ts:39-52`, `lib/jmap/client.ts:8085-8142`; changelog 1.9.x "Stop sending notifications for spam").
|
||||
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`src/lib/push-notifications.ts:104`); `createPushSubscription` has no `emailPush` support (`src/api/push.ts:23-57`). Every read/flag/move on any device wakes the relay -> FCM -> headless task, which then re-queries the inbox (`src/lib/push-background-task.ts:452-455`); a spam delivery still triggers a push (only the inbox `notKeyword $seen` query hides it, at the cost of a wake-up).
|
||||
- Fix hint: subscribe to `EmailDelivery` only; add `hasEmailPushCapability()` + `emailPush` filter as in WEB; the relay must forward it (see memory note: relay lacks EmailPush).
|
||||
|
||||
- [x] **Headless push task re-binds the singleton client while the UI may be running** — fixed in 1f2df5f — `P2` — `rn-only-bug`
|
||||
- What WEB does: N/A (service worker has its own fetch path).
|
||||
- What RN does: `processAccountForPush` calls `jmapClient.loadAccount(accountId)` for each candidate account (`src/lib/push-background-task.ts:445`) and the `finally` re-loads the active one (`438-440`, errors swallowed). HeadlessJS tasks run inside the live RN instance when the app is foregrounded, so UI requests issued during that window are sent with another account's credentials/session, and a failing final `loadAccount` leaves `session = null` (`jmap-client.ts:269-270`) until the next network event.
|
||||
- Fix hint: use a detached fetch path like `src/api/unified-inbox.ts:116-131` (credentials read from SecureStore, own `apiUrl`) instead of mutating the singleton.
|
||||
|
||||
### Offline cache & outbox
|
||||
|
||||
- [x] **Outbox retries forever on errors that look transient but are permanent** — `P2` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: `isTransientNetworkError` treats any `TypeError` and messages containing `session expired`/`not connected` as transient (`src/lib/network-error.ts:48-63`). (a) A JMAP method error makes the unchecked read path throw `TypeError: Cannot read property 'list' of undefined` (see method-error finding) -> transient -> `recordError(..., false)` never increments `attempts` (`src/stores/outbox-store.ts:203-206`, `243-254`) -> the queue is wedged on that entry until reinstall. (b) `AuthenticationError('Session expired')` from a revoked password is also transient -> same wedge. WEB has no outbox (N/A) but never mis-classifies auth errors as transient.
|
||||
- Fix hint: classify by error class (`NetworkError`, `RequestTimeoutError`, `RateLimitError`) not by `TypeError`/message; treat `AuthenticationError` as terminal and pause the queue.
|
||||
|
||||
- [x] **Permanently failed outbox ops are dropped silently; optimistic state never reverted** — `P2` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: after 5 attempts the entry is removed with `console.warn` (`src/stores/outbox-store.ts:210-216`); the list/cache was already patched optimistically (`offline-cache-store.patch`, email-store mutations) and nothing refetches, so the UI shows a move/flag the server rejected until the next full refresh; `OfflineBanner` only shows the count.
|
||||
- Fix hint: keep a `failed: OutboxEntry[]` in the store, surface it in `OfflineBanner`/AboutDataSettings, and trigger `refreshEmails()` for the affected mailbox when an op is dropped.
|
||||
|
||||
- [x] **Interrupted flush is not resumed until the next online transition or launch** — `P3` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: `flush()` breaks on the first transient error (`src/stores/outbox-store.ts:203-206`); the only triggers are `haveLiveSession` and NetInfo `online` flips (`App.tsx:289-295`). A blip that NetInfo never reports (server hiccup) leaves the queue idle.
|
||||
- Fix hint: schedule a retry with back-off after a transient break; also flush on AppState `active`.
|
||||
|
||||
- [x] **Offline `onlineRun` fallback silently changes semantics (archive auto-foldering)** — `P3` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: `applyOrQueueBatch` runs the richer `onlineRun` (e.g. year/month archive foldering) and on transient failure re-queues the plain `mailboxes` primitives (`src/stores/outbox-store.ts:279-292`), so the replay lands in the top-level Archive without the year/month folder the user configured.
|
||||
- Fix hint: encode the folder creation into the queued op (or queue an `archive` op kind that re-runs the foldering logic on replay).
|
||||
|
||||
- [x] **`Email/changes.hasMoreChanges` ignored in the incremental refresh** — `P3` — `partial` — fixed in f07e039
|
||||
- What WEB does: WEB refetches the page instead of diffing, so it is not exposed.
|
||||
- What RN does: `getEmailChanges` returns `hasMoreChanges` (`src/api/email.ts:355-376`) but `refreshEmails` uses `updated`/`destroyed` once (`src/stores/email-store.ts:836-846`); after a long offline gap the truncated lists leave stale keywords on some rows until the snapshot is invalidated. (The mailbox path does drain, `email-store.ts:575-579`, `601`.)
|
||||
- Fix hint: loop while `hasMoreChanges`, or drop the snapshot and fall back to the full re-query when it is set.
|
||||
|
||||
- [x] **Offline cache + outbox land in Android auto-backup** — `P2` — `missing` (security) — fixed in 7f9f5af
|
||||
- What WEB does: N/A (no persistent mail cache).
|
||||
- What RN does: `android:allowBackup="true"` with `@xml/secure_store_backup_rules` from the expo-secure-store plugin, which only excludes SecureStore's own prefs (`android/app/src/main/AndroidManifest.xml:16`); the AsyncStorage SQLite DB (full cached bodies `webmail:offline-cache:entry:v2:*`, outbox, settings, account registry) is backed up to the user's Google account.
|
||||
- Fix hint: add an app-owned `fullBackupContent`/`dataExtractionRules` excluding `databases/RKStorage` (AsyncStorage) or set `allowBackup=false` via an Expo config plugin.
|
||||
|
||||
- [ ] **Send has no offline path and no draft fallback** — `P2` — `missing`
|
||||
- What WEB does: no offline queue either, but send failures leave the composer open with the draft autosaved (composer agent's area); send itself has a deadline (#702).
|
||||
- What RN does: `ComposeScreen` shows `Alert('Send failed')` and keeps the modal (`src/screens/ComposeScreen.tsx:975-979`); there is no draft API in `src/api/email.ts` (`grep -n '[Dd]raft' src/api/email.ts` only finds the send create id), so the body is lost if the user leaves, and an offline send is not queued although the outbox exists.
|
||||
- Fix hint: either add an `outbox` `send` op kind (blob uploads must already be done) or at minimum persist the composer state locally on failure.
|
||||
|
||||
### Security hardening (RN)
|
||||
|
||||
- [x] **`Linking.openURL` with unvalidated URIs from server data (contacts, calendar invitations)** — `P2` — `rn-only-bug` — fixed in 96272b6 (contacts 63bf392, calendar/invitation links confirm the host)
|
||||
- What WEB does: links are sanitized in the viewer; iCal/vCard URLs open via anchor with `rel=noopener` (viewer agent's area); `isValidUnsubscribeUrl` gates unsubscribe links (`components/email/unsubscribe-banner.tsx:63`).
|
||||
- What RN does: `openUrl(uri)` on a contact's stored URL (`src/screens/ContactDetailScreen.tsx:173-175`) and `Linking.openURL(videoUri)` on `virtualLocations[0].uri` from an incoming invitation (`src/components/calendar/EventDetailSheet.tsx:169`, `234`; `src/components/email/CalendarInvitationBanner.tsx:92`, `159`) accept any scheme: `intent://...#Intent;component=...;end` launches arbitrary exported activities on Android, `file://`, `content://`, or third-party deep links. EmailBodyView already restricts to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:715-723`).
|
||||
- Fix hint: a shared `openExternalUrl()` that allows only `https?:`, `mailto:`, `tel:`, `sms:` and shows the host in a confirmation for invitation links.
|
||||
|
||||
- [x] **Password handoff over a custom-scheme redirect (`bulwarkmobile://…#password=`) is interceptable; OAuth `state` uses `Math.random`** — `P1` — `rn-only-bug` — fixed in 2c0dbd1 (CSPRNG state + endpoint validation; moving the password out of the fragment is a webmail change)
|
||||
- What WEB does: the mobile handoff page redirects with credentials in the fragment (`lib/deep-link-handoff.ts`, changelog "Mobile handoff page with JMAP authentication verification").
|
||||
- What RN does: the `password` flow returns the clear-text password in the redirect fragment (`src/lib/oauth.ts:92-99`); the scheme is a plain `intent-filter` (`AndroidManifest.xml:25-30`), which any installed app can also register (no App Links verification), so a malicious app can receive the redirect. `state` is 16 bytes of `Math.random` (`oauth.ts:41-47`) - it prevents a forged callback from being *accepted* only if unpredictable; and the fragment's `server_url`, `token_endpoint`, `client_id` are trusted as-is (`oauth.ts:101-121`), so a forged callback can point refresh traffic at an attacker host.
|
||||
- Fix hint: never send the password in the redirect (mint short-lived tokens or a one-time pairing code like `redeemPairingCode`, `oauth.ts:165-208`); generate `state` with `expo-crypto` `getRandomBytes`; validate `server_url`/`token_endpoint` are https and the token endpoint host matches the server host.
|
||||
|
||||
- [x] **`http://` server URLs accepted without a warning; cleartext then fails opaquely** — `P2` — `missing` — fixed in ce97232
|
||||
- What WEB does: warns when the JMAP URL points at a local-only host (changelog "Setup: Warn when the JMAP URL points at a local-only host"); Basic auth travels server-to-server.
|
||||
- What RN does: `normalizeServerInput` accepts `http` (`src/lib/server-discovery.ts:28-30`), QR payloads accept `http` (`src/lib/oauth.ts:144`, `155`), `connect()` never checks the scheme. Android has no `usesCleartextTraffic` (`app.config.js` has no `android.usesCleartextTraffic`; manifest has none) so http fails with "Network request failed" on API 28+, while iOS ATS may or may not allow it, so the user gets an unexplained error - or, worse, Basic credentials in clear on iOS.
|
||||
- Fix hint: reject `http://` in `normalizeServerInput` except `localhost`/RFC-1918 in dev builds, with a clear message.
|
||||
|
||||
- [x] **Weak randomness for TOTP secret / UUIDs (falls back to `Math.random`)** — `P2` — `rn-only-bug` — fixed in 2c0dbd1
|
||||
- What WEB does: server-side `otpauth` `Secret({size:20})` from Node crypto; `generateUUID()` guarded (changelog "Replace unguarded crypto.randomUUID() with safe generateUUID()").
|
||||
- What RN does: `randomBytes` tries `crypto.getRandomValues`, then `crypto.randomUUID`, then `Math.random` (`src/lib/totp.ts:404-437`). `expo-crypto` is not a dependency (`package.json`), Hermes exposes no `crypto` global by default, so a long-lived 2FA secret is minted from `Math.random`; the same fallback is documented for `lib/uuid.ts` and `lib/oauth.ts`.
|
||||
- Fix hint: add `expo-crypto` and use `Crypto.getRandomBytes(20)` / `Crypto.randomUUID()`; remove the Math.random branches.
|
||||
|
||||
- [x] **Client-cert native fetch follows redirects with Authorization and turns POST into GET (#627 class)** — `P3` — `bugfix-parity` — fixed in 6f747db
|
||||
- What WEB does: `postJmap` follows redirects manually, keeps POST, only to the same host or an https upgrade (`lib/stalwart/jmap-api.ts:44-93`).
|
||||
- What RN does: `instanceFollowRedirects = true` on `HttpURLConnection` (`android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt:144`); a 301 in front of `/jmap/` re-issues as GET (Stalwart answers 404 problem+json) and cross-host redirects re-send the header. The non-cert `fetch` path has the same POST->GET behaviour but that matches browser semantics WEB also lives with client-side.
|
||||
- Fix hint: `instanceFollowRedirects = false`, return the 3xx to JS, and handle the redirect in `secureFetch` with the same host/https-upgrade rule.
|
||||
|
||||
- [x] **Client-cert path buffers whole bodies through base64 over the bridge with a 30 s cap** — `P3` — `rn-only-bug` — fixed in 6f747db (blob deadline raised to 300 s; streaming still deferred)
|
||||
- What RN does: the whole response is read into memory and base64-encoded (`BulwarkClientCertModule.kt:170-190`, `src/lib/client-cert.ts:204-216`); attachment downloads for cert users go through `secureFetch(...).arrayBuffer()` (`src/lib/email-export.ts:100-110`) and uploads through `bodyToBase64` (`client-cert.ts:171-188`), so a 50 MB attachment costs ~200 MB of JS heap and can time out at 30 s (`client-cert.ts:250`; WEB uses 300 s for transfers, `lib/jmap/client.ts:634`).
|
||||
- Fix hint: stream to a file in the native module (`FileOutputStream`) and return the path; raise the transfer timeout.
|
||||
|
||||
- [x] **Blob downloads via `File.downloadFileAsync` bypass token refresh** — `P2` — `rn-only-bug` — fixed in d2ed27f
|
||||
- What WEB does: `fetchBlob` goes through `authenticatedFetch`, which refreshes a Bearer token on 401 (`lib/jmap/client.ts:4150-4157`, `855-863`).
|
||||
- What RN does: `downloadInto` builds the header from `jmapClient.authHeader` without `ensureFreshToken()` and does not retry on 401 (`src/lib/email-export.ts:93-97`); after the access token expires, attachment/EML download fails until some other JMAP call has refreshed the token. `fetchBlobArrayBuffer` does refresh (`src/api/jmap-client.ts:575-581`).
|
||||
- Fix hint: expose `ensureFreshToken()` publicly, call it before `downloadFileAsync`, and on 401 call `forceRefreshToken()` and retry once.
|
||||
|
||||
- [x] **Downloaded attachment temp files are never cleaned up** — `P3` — `rn-only-bug` — fixed in d2ed27f (launch sweep wired in App.tsx)
|
||||
- What RN does: `shareAttachment`/`openAttachment` write into `Paths.cache` (`src/lib/email-export.ts:127`, `195`) and only delete when the same name is reused; the APK installer deletes on failure (`src/lib/install-update.ts:78`, `143`). Cache may be purged by the OS but shared files linger (readable to apps that were granted the URI).
|
||||
- Fix hint: delete the file after the share sheet resolves, and sweep `Paths.cache` older than a day at launch.
|
||||
|
||||
- [x] **WebView: `data:` navigations allowed inside the message frame** — `P3` — `partial` — fixed in 06742ef
|
||||
- What WEB does: message HTML is sandboxed in an `srcDoc` iframe with a CSP meta tag (changelog 1.5.x); `data:` top-level navigation is blocked by the browser.
|
||||
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:715-718`), so `<a href="data:text/html,...">` replaces the message with attacker HTML rendered inside the app chrome (phishing form); the WebView is otherwise well hardened (`originWhitelist=['about:blank']`, file access off, `mixedContentMode="never"`, `setSupportMultipleWindows={false}`, `incognito`, `domStorageEnabled={false}`, `EmailBodyView.tsx:675-712`).
|
||||
- Fix hint: allow `data:` only for `request.isTopFrame === false` or block it entirely (the body is loaded via `source={{html}}`, not a data: URL).
|
||||
|
||||
- [ ] **No screenshot / recent-apps protection option** — `P3` — `missing`
|
||||
- What WEB does: N/A.
|
||||
- What RN does: no `FLAG_SECURE`/`expo-screen-capture` anywhere; mail content appears in the Android recents thumbnail.
|
||||
- Fix hint: optional "Hide content in app switcher" setting using `expo-screen-capture` `preventScreenCaptureAsync`.
|
||||
|
||||
- [x] **Sender/subject of every FCM message logged to logcat** — fixed in 00faceb — `P3` — `rn-only-bug`
|
||||
- What RN does: `console.log('[push] fcm message', payload.title)` in production (`App.tsx:301-303`). No credential logging found (grep for password/token/secret in console calls is empty).
|
||||
- Fix hint: drop or guard with `__DEV__`.
|
||||
|
||||
- [x] **Custom SHA-256 implementation (verified correct) could use `expo-crypto`** — `P3` — `rn-only-bug` — fixed in n/a (kept: PKCE needs a synchronous digest; expo-crypto digest is async)
|
||||
- What RN does: `src/lib/sha256.ts` is a straight FIPS 180-4 implementation (padding, 64-bit length split, unsigned hex output all correct) used only for APK checksum verification (`src/lib/install-update.ts:118-127`); verification is opt-in when the release body carries a hash.
|
||||
- Fix hint: once `expo-crypto` is added (see randomness finding) replace with `Crypto.digest('SHA-256', bytes)`; keep the size check.
|
||||
|
||||
- [x] **TOTP helper: RFC 6238 compatible; issuer hard-coded** — `P3` — `partial` — fixed in ce97232
|
||||
- What WEB does: server-verified via `x:AccountPassword/set otpAuth.otpCode`; enrolment via `otpauth` with the app/brand as issuer.
|
||||
- What RN does: `generateTotpEnrolment` produces `otpauth://totp/Stalwart:<label>?secret=…&algorithm=SHA1&digits=6&period=30` (`src/lib/totp.ts:469-485`) - correct parameters, base32 without padding correct; verification is server-side (comment `totp.ts:391-396`). Issuer `Stalwart` instead of the server host/brand.
|
||||
- Fix hint: use the server hostname (or configured brand) as issuer.
|
||||
|
||||
- [x] **Relay base URL stored unvalidated (http allowed)** — `P3` — `rn-only-bug` — fixed in f6f26f7
|
||||
- What RN does: `setStoredRelayBaseUrl` only trims slashes (`src/lib/push-notifications.ts:170-176`); the relay receives the FCM token and the JMAP push-subscription URL slot. WEB's telemetry/push endpoints are validated server-side against internal hosts (changelog "Block telemetry endpoint from pointing at internal/loopback hosts").
|
||||
- Fix hint: require `https://` in the settings input.
|
||||
|
||||
### Stalwart admin/self-service, S/MIME, encryption at rest
|
||||
|
||||
- [x] **Encryption at rest is read-only; no public-key management** — fixed in ddae1e6 — `P2` — `partial` — fixed in 69bedf0 (API; settings UI with the settings agent)
|
||||
- What WEB does: `fetchCryptoInfo`/`updateEncryptionAtRest` with `@type` (`Disabled|Aes128|Aes256`), `publicKey`, `encryptOnAppend`, `allowSpamTraining` (spam training before encryption) and `x:PublicKey/query|get|set` CRUD with `emailAddresses`/`expiresAt` (`stores/account-security-store.ts:355-448`, `625-700`; changelog 1.7.x "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest", 1.9.x "Per-account isolation for encryption at rest").
|
||||
- What RN does: `fetchEncryptionType` returns only the `@type` (`src/api/account-security.ts:150-155`); shown read-only in `AccountSecuritySettings.tsx:595`. No `x:PublicKey/*`, no set path. Per-account isolation is inherent (single-session client uses `jmapClient.accountId`).
|
||||
- Fix hint: port `updateEncryptionAtRest`, `fetchPublicKeys`, `createPublicKey`, `removePublicKey` into `account-security.ts` (same `STALWART_USING`) and a key list/paste screen.
|
||||
|
||||
- [x] **S/MIME settings screen is a mock; no S/MIME detection in the viewer** — `P2` — `missing` — fixed in 06742ef (viewer banner; settings stub hidden in 3d0d440; crypto deferred)
|
||||
- What WEB does: built-in S/MIME was removed from core into the privileged crypto plugin (changelog 1.5.0 note, "S/MIME: The built-in S/MIME implementation has been removed from core"); core keeps only detection (`isSmimeEmail`, `lib/jmap/client.ts:484-505`), the range-limited blob read for signature checks (`7503-7550`), and the plugin hook surface. Per-account key isolation, legacy 3DES/PBE and self-signed detection live in the plugin.
|
||||
- What RN does: `SmimeSettings` renders `MOCK_KEYS`/`MOCK_CERTS` and three settings that nothing reads (`src/components/settings/SmimeSettings.tsx:32-61`; `src/stores/settings-store.ts:203-205`, `308-310`), reachable from Settings as `encryption` (`src/screens/SettingsScreen.tsx:130`). Nothing detects `application/pkcs7-mime`/`multipart/signed` bodies, so an encrypted message shows as an empty body with a `smime.p7m` attachment.
|
||||
- Fix hint: parity for RN *core* = (1) port `isSmimeEmail` and show a "signed/encrypted with S/MIME - not supported on mobile" banner, (2) remove or hide the mock section. Full sign/verify/decrypt needs WebCrypto, which Hermes lacks: it would require `react-native-quick-crypto` (OpenSSL) plus a PKCS#7/CMS parser (`pkijs` runs on WebCrypto and would need a shim), private keys in SecureStore/Keychain, and per-account isolation of the key store; there is no plugin tier in RN to host it. Treat as a separate project.
|
||||
|
||||
- [x] **Display name not refreshed from the Stalwart principal on login/restore/switch (#900)** — `P3` — `bugfix-parity` — fixed in b52768e
|
||||
- What WEB does: refreshes the account label from `x:Account/get` on login, restore and switch (changelog 1.9.x "#900"; memory note: principal name is copied into the Identity only once).
|
||||
- What RN does: `fetchPrincipal` exists (`src/api/account-security.ts:157-181`) but is only called from the security screen (`AccountSecuritySettings.tsx:595`); `addAccount` uses `displayName: username` (`src/stores/auth-store.ts:187-195`, `123-131`).
|
||||
- Fix hint: call `fetchPrincipal()` (guarded by `isStalwartSupported()`) after `applyConnectedState` and `updateAccount({displayName})`.
|
||||
|
||||
- [x] **Native issue #47 "Account security not accessible" - likely gating** — `P3` — `rn-only-bug` (unverified) — fixed in 032732c
|
||||
- What RN does: the screen hides password/TOTP for Bearer sessions (`src/api/jmap-client.ts:66-68`) and shows "not available" when `urn:stalwart:jmap` is absent (`src/api/account-security.ts:51-55`); an OAuth handoff login therefore has no password management even on Stalwart. WEB shows the same sections for OAuth users via the passthrough with stored basic credentials.
|
||||
- Fix hint: confirm against the issue; consider allowing app-password/API-key management for Bearer sessions (they only need `x:*` methods, not the password).
|
||||
|
||||
### Newsletter unsubscribe
|
||||
|
||||
- [x] **List-Unsubscribe not parsed or offered** — `P2` — `missing` — fixed in d2ed27f
|
||||
- What WEB does: `extractListHeaders` parses `List-Unsubscribe`/`List-Id`/`List-Help`/`List-Post` (`lib/email-headers.ts:253-300`); the banner opens http links in a new tab (no RFC 8058 one-click POST) and sends `mailto:` unsubscribes itself via JMAP (`components/email/unsubscribe-banner.tsx:63-110`; changelog 1.5.x "Send mailto: unsubscribe ourselves instead of via the OS handler").
|
||||
- What RN does: `EMAIL_FULL_PROPERTIES` requests no headers (`src/api/email.ts:11-15`); no parsing, no banner (`grep -rln unsubscribe src` hits only unrelated store code).
|
||||
- Fix hint: add `header:List-Unsubscribe:asText` and `header:List-Unsubscribe-Post:asText` to the full properties; port `parseUnsubscribeUrls`; for http+`List-Unsubscribe=One-Click` do the RFC 8058 POST (`application/x-www-form-urlencoded`, body `List-Unsubscribe=One-Click`) in-app, otherwise open the link; for `mailto:` reuse `sendEmail`.
|
||||
|
||||
### Misc network
|
||||
|
||||
- [x] **Unified inbox detached fetch: no timeout, no 429/`maxConcurrentRequests` handling, N parallel sessions** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: per-account clients share the retry/timeout/rate-limit pipeline; unified fetch errors are collected per account (`stores/email-store.ts:3325-3345`).
|
||||
- What RN does: `jmapPost`/`fetchInboxForAccount` do a bare `secureFetch` per account and per shared account in parallel (`src/api/unified-inbox.ts:116-131`, `215-233`), swallowing shared-account errors and only mapping 401 to "Session expired".
|
||||
- Fix hint: route through the same `request()` helper once it has deadlines/back-off (pass explicit credentials instead of the singleton).
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Session discovery, `primaryAccounts` selection (mail -> core -> first account), shared/group account detection (`src/api/jmap-client.ts:388-401`, `480-493` vs `lib/jmap/client.ts:988-995`, `4421-4463`).
|
||||
- Per-capability account ids for Sieve and Files (`src/api/sieve.ts:20-23`, `src/api/files.ts:27-31`); calendar/contacts use the primary account on both sides in practice.
|
||||
- OAuth proactive refresh with 60 s leeway, reactive refresh on 401, single-flight refresh promise, refresh-token rotation fallback (`src/api/jmap-client.ts:193-221`, `src/lib/oauth.ts:210-265`).
|
||||
- Blob download/upload URL templating with `encodeURIComponent` and RN-safe string splitting (no `new URL()`), shared-account `accountId` override (`src/api/jmap-client.ts:557-566`, `src/api/blob.ts`).
|
||||
- Upload response parsing accepts both `{blobId}` and `{[accountId]: {blobId}}` shapes (`src/api/blob.ts:38-61`).
|
||||
- `Mailbox/changes` incremental sync with `hasMoreChanges` drain and `cannotCalculateChanges` fallback; folder tree kept on failure (#780 behaviour) (`src/stores/email-store.ts:528-612`).
|
||||
- `Email/queryChanges` + `Email/changes` incremental list refresh with snapshot guard (issue #10) and full re-query fallback (`src/stores/email-store.ts:770-980`).
|
||||
- `null` for patch removal, `false` stripped from `mailboxIds` maps (`src/api/email.ts:456`, `551-556`).
|
||||
- FilterOperator/FilterCondition kept separate in query filters (`src/api/email.ts:225-237`).
|
||||
- `getSharedMailboxes` chunks to `maxCallsInRequest` (`src/api/email.ts:84-105`); `getEvents`/`getContacts`/`fetchEmailsChunked`/offline sync chunk to `maxObjectsInGet`.
|
||||
- Send: `/error`, `notCreated` checked; HOLDFOR envelope for scheduled send; FUTURERELEASE capability check (`src/api/email.ts:836-873`, `src/api/jmap-client.ts:505-531`).
|
||||
- Push subscription create/verify/update/destroy with `using: [core]` and 90-day expiry refresh (`src/api/push.ts:9-114`, `src/lib/push-notifications.ts`).
|
||||
- Outbox: idempotent full-state ops, per-account buckets, destroy-wins coalescing, oldest-first replay, account-switch guards (`src/stores/outbox-store.ts`).
|
||||
- Offline cache: per-account buckets, index + entry keys, eviction by `receivedAt`, optimistic `patch`, abort on account switch (`src/stores/offline-cache-store.ts`); `getEmailDetail` cache fallback.
|
||||
- Credentials in `expo-secure-store` (never AsyncStorage); SecureStore key sanitising; per-account keys; legacy migration.
|
||||
- Client-cert module uses the platform default `X509TrustManager` (no trust-all) (`BulwarkClientCertModule.kt:216-222`).
|
||||
- WebView hardening: `originWhitelist=['about:blank']`, no file/universal access, `mixedContentMode="never"`, no multiple windows, no cookies/DOM storage, `incognito`, external links limited to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:675-724`).
|
||||
- No secrets in `console.*` output; `usesNonExemptEncryption: false` declared (`app.config.js:370`).
|
||||
- Stalwart self-service via `urn:stalwart:jmap`: password change, display name, TOTP enable/disable, app passwords and API keys with IP allow-list, principal read (`src/api/account-security.ts`), with proper `error`/`notUpdated` checks.
|
||||
- SHA-256 implementation correct (FIPS 180-4) and APK size + optional checksum verification (`src/lib/sha256.ts`, `src/lib/install-update.ts:100-127`).
|
||||
|
||||
## N/A on mobile
|
||||
- Server-side SSRF/DNS-rebinding guard, endpoint allow-list, `OAUTH_ALLOW_PRIVATE_ENDPOINTS`, IPv6 transition-address checks (`lib/security/url-guard.ts`, `lib/stalwart/server-fetch.ts`; GHSA-24w9): RN talks to the server directly from the device; the only analogue is scheme validation (covered above).
|
||||
- CSP `frame-src`/`connect-src`/`object-src` changes, `srcDoc` iframe sandbox, plugin bundle signing, admin dashboard, setup wizard, `SESSION_SECRET`, session-cookie slots, Stalwart JMAP passthrough (#627) and auth-context binding: RN has no proxy tier; it holds credentials itself and calls `x:*` methods directly.
|
||||
- `MAX_SSE_STREAMS`/per-tab socket budget (#702), "one SSE stream per client", browser `visibilitychange`/`online` events, `document.hidden` poll pausing: RN is single-session; the equivalent concern is AppState (listed as a gap).
|
||||
- Service-worker PWA caching, `blob:` object URLs, `URL.revokeObjectURL`, `window.open` for unsubscribe links.
|
||||
- WEB S/MIME plugin (privileged same-origin plugin tier), `onBeforeBlobUpload` offload hooks, plugin crypto API: no plugin system in RN.
|
||||
- `x-www-form-urlencoded` TOTP token exchange on the WEB server (`/api/auth/totp`): RN verifies TOTP through Stalwart's `otpCode` directly.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+821
-62
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+822
-58
File diff suppressed because it is too large
Load Diff
+905
-61
File diff suppressed because it is too large
Load Diff
+855
-91
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
+855
-91
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+856
-92
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
+891
-127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,751 @@
|
||||
{
|
||||
"advanced_search": {
|
||||
"after": "After",
|
||||
"before": "Before",
|
||||
"no_attachment": "No attachment",
|
||||
"not_starred": "Not starred"
|
||||
},
|
||||
"calendar": {
|
||||
"agenda": {
|
||||
"days_clear": "The next {count} days are clear.",
|
||||
"no_upcoming": "No upcoming events"
|
||||
},
|
||||
"drawer": {
|
||||
"my_calendars": "My calendars",
|
||||
"no_calendars": "No calendars yet.",
|
||||
"shared_with_me": "Shared with me",
|
||||
"subscribed": "Subscribed"
|
||||
},
|
||||
"event_modal": {
|
||||
"participant_placeholder": "Add participant by email"
|
||||
},
|
||||
"events": {
|
||||
"tap_to_create": "Tap + to create one"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, agenda…",
|
||||
"location_placeholder": "Place or address",
|
||||
"time": "Time"
|
||||
},
|
||||
"invitation": {
|
||||
"accept": "Yes",
|
||||
"add_error": "Could not add the event",
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"added": "Added to calendar",
|
||||
"already_in_calendar": "Already in your calendar",
|
||||
"cancelled": "This event was cancelled",
|
||||
"decline": "No",
|
||||
"join_video": "Join video call",
|
||||
"no_writable_calendar": "No writable calendar available",
|
||||
"organizer": "Organizer",
|
||||
"reading": "Reading invitation…",
|
||||
"reply": "A participant replied to this invitation",
|
||||
"response_error": "Could not send your response",
|
||||
"response_sent": "Response sent",
|
||||
"tentative": "Maybe",
|
||||
"title": "Calendar invitation",
|
||||
"trust_verified": "Sender verified"
|
||||
},
|
||||
"management": {
|
||||
"clear_confirm": "Remove all",
|
||||
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
|
||||
"clear_title": "Remove all events?",
|
||||
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
|
||||
"delete_title": "Delete calendar?",
|
||||
"description_placeholder": "Optional",
|
||||
"edit_calendar": "Edit calendar",
|
||||
"new_calendar": "New calendar"
|
||||
},
|
||||
"settings": {
|
||||
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
|
||||
"time_zone": "Time zone",
|
||||
"time_zone_auto": "Device time zone",
|
||||
"time_zone_desc": "Zone used for new events and for interpreting the calendar. \"Device\" follows the phone."
|
||||
},
|
||||
"share": {
|
||||
"add_people": "Add people",
|
||||
"error": "Failed to update sharing",
|
||||
"no_matches": "No matches",
|
||||
"no_principals": "Sharing is not available on this server.",
|
||||
"role_custom": "Custom",
|
||||
"search_placeholder": "Search by name or email",
|
||||
"shared_with": "Shared with",
|
||||
"title": "Share"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"back": "Back",
|
||||
"confirm": "Confirm",
|
||||
"create": "Create",
|
||||
"dismiss": "Dismiss",
|
||||
"done": "Done",
|
||||
"remove": "Remove",
|
||||
"retry": "Retry",
|
||||
"undo": "Undo",
|
||||
"update": "Update"
|
||||
},
|
||||
"context_menu": {
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Camera",
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
"attachment_reminder_title": "Forgot an attachment?",
|
||||
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
|
||||
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"identity_unavailable": "Identity unavailable",
|
||||
"image_load_failed": "Could not load image",
|
||||
"no_recipient": "(no recipient)",
|
||||
"no_sent_folder": "No Sent folder found",
|
||||
"permission_camera": "Camera permission is required to take a photo.",
|
||||
"permission_photos": "Photo library permission is required to attach images.",
|
||||
"recipient_add_contact": "Add to contacts",
|
||||
"recipient_copy": "Copy address",
|
||||
"recipient_move_to": "Move to {field}",
|
||||
"recipient_remove": "Remove",
|
||||
"save_draft": "Save Draft",
|
||||
"schedule_custom": "Pick date & time…",
|
||||
"schedule_in_1h": "In 1 hour",
|
||||
"schedule_in_3h": "In 3 hours",
|
||||
"schedule_past_body": "The scheduled time must be in the future.",
|
||||
"schedule_past_title": "Pick a future time",
|
||||
"schedule_tomorrow": "Tomorrow, 8:00 AM",
|
||||
"schedule_tomorrow_morning": "Tomorrow morning",
|
||||
"schedule_too_late_body": "That is later than this server allows. Pick an earlier time.",
|
||||
"schedule_too_late_title": "Too far ahead",
|
||||
"schedule_unsupported_body": "This mail server does not support scheduled send.",
|
||||
"schedule_unsupported_title": "Scheduling unavailable",
|
||||
"scheduled_body": "Your message will be sent at {time}.",
|
||||
"scheduled_title": "Scheduled",
|
||||
"send_failed": "Failed to send",
|
||||
"send_now": "Send now",
|
||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"undo_send_label": "Sending in {seconds}s…",
|
||||
"uploading": "Uploading...",
|
||||
"validation": {
|
||||
"subject_required": "Add a subject"
|
||||
}
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Moving between accounts needs a connection",
|
||||
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
||||
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
||||
"error": "Error",
|
||||
"marked_as_spam": "Marked as spam",
|
||||
"marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam",
|
||||
"marked_not_spam": "Marked as not spam",
|
||||
"marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam",
|
||||
"move_same_account": "Messages can only be moved within the same account",
|
||||
"moved_to_trash": "Email moved to Trash",
|
||||
"moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash",
|
||||
"no_archive_folder": "Could not find an Archive folder on the server.",
|
||||
"no_emails_in": "No emails in {folder}",
|
||||
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
|
||||
"no_mailboxes": "No mailboxes found",
|
||||
"no_mailboxes_hint": "Check that your JMAP account has mail capability.",
|
||||
"no_tags_defined": "No tags defined. Add tags in Settings.",
|
||||
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.",
|
||||
"offline_nothing_cached": "No connection. Showing nothing because no mail has been cached yet.",
|
||||
"open_draft_failed": "Could not open the draft.",
|
||||
"outbox_discard": "Discard",
|
||||
"outbox_failed": "{count} changes could not be saved to the server.",
|
||||
"recent_searches": "Recent searches",
|
||||
"scope_all_folders": "All folders",
|
||||
"scope_this_folder": "This folder",
|
||||
"search_placeholder": "Search mail...",
|
||||
"showing_cached": "Showing cached mail",
|
||||
"swipe_move": "Move",
|
||||
"swipe_spam": "Spam",
|
||||
"total": "total",
|
||||
"undo_failed": "Undo failed",
|
||||
"unknown_tag": "not defined here"
|
||||
},
|
||||
"email_viewer": {
|
||||
"add_sender_to_contacts": "Add sender to contacts",
|
||||
"address_actions": {
|
||||
"add_contact": "Add to contacts",
|
||||
"compose": "New message",
|
||||
"copy": "Copy address",
|
||||
"copy_failed": "Could not copy the address."
|
||||
},
|
||||
"attachment_actions": {
|
||||
"open": "Open",
|
||||
"preview": "Preview",
|
||||
"save": "Save",
|
||||
"share": "Share"
|
||||
},
|
||||
"attachment_failed": "Could not open attachment",
|
||||
"authentication": {
|
||||
"spoof_warning": "The sender address could not be verified - this message may be spoofed."
|
||||
},
|
||||
"bundle_too_large": "The attachments are too large to bundle.",
|
||||
"color_tag": {
|
||||
"blue": "Blue",
|
||||
"green": "Green",
|
||||
"none": "None",
|
||||
"orange": "Orange",
|
||||
"pink": "Pink",
|
||||
"purple": "Purple",
|
||||
"red": "Red",
|
||||
"title": "Color Tag",
|
||||
"yellow": "Yellow"
|
||||
},
|
||||
"delete_permanently_message": "This message will be deleted permanently and cannot be recovered.",
|
||||
"delete_permanently_title": "Delete permanently?",
|
||||
"export_failed": "Export failed",
|
||||
"forwarded": "Forwarded",
|
||||
"load_failed": "Failed to load email",
|
||||
"preview": {
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
"replied": "Replied",
|
||||
"sender_actions": "Sender…",
|
||||
"set_color": "Set tag",
|
||||
"show_all_count": "Show all ({count})",
|
||||
"show_less": "Show less",
|
||||
"smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile",
|
||||
"source_too_large": "The message is too large to display in full. Share the full source instead.",
|
||||
"thread_loading": "Loading conversation...",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "No sending identity available"
|
||||
},
|
||||
"view_dark_mode": "View in dark mode",
|
||||
"view_light_mode": "View in light mode"
|
||||
},
|
||||
"files": {
|
||||
"done": "Done",
|
||||
"share_add_people": "Add people",
|
||||
"share_empty": "Not shared with anyone yet",
|
||||
"share_error": "Failed to update sharing",
|
||||
"share_folder_hint": "Sharing a folder shares everything inside it.",
|
||||
"share_no_matches": "No matches",
|
||||
"share_no_users": "No other users found",
|
||||
"share_remove": "Remove access",
|
||||
"share_role_custom": "Custom",
|
||||
"share_search_placeholder": "Search by name or email",
|
||||
"shared_with": "Shared with"
|
||||
},
|
||||
"identities": {
|
||||
"badge": {
|
||||
"to": "to {identity}",
|
||||
"via": "via {identity}"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"mobile": {
|
||||
"add_subtitle": "You're signed in to these already.",
|
||||
"add_title": "Add another account",
|
||||
"camera_allow": "Allow camera access",
|
||||
"camera_disabled": "Camera access is disabled. Enable it in Settings to scan a sign-in QR code.",
|
||||
"camera_needed": "Bulwark Mail needs camera access to scan a sign-in QR code.",
|
||||
"choose_subtitle": "Choose how you'd like to sign in.",
|
||||
"confirm_check": "Check this looks right",
|
||||
"confirm_found": "Found your mail server",
|
||||
"confirm_subtitle": "Next you'll sign in on your provider's own page.",
|
||||
"connecting": "Connecting to {host}",
|
||||
"connecting_detail": "Setting up your mailbox…",
|
||||
"continue": "Continue",
|
||||
"different_server": "Use a different server",
|
||||
"elsewhere_title": "An account somewhere else",
|
||||
"email_desc": "We'll find your mail server",
|
||||
"email_step_hint": "Works with Stalwart and any other JMAP mail server.",
|
||||
"email_step_subtitle": "That's usually all we need to find your mail server.",
|
||||
"email_step_title": "What's your email address?",
|
||||
"email_step_title_add": "What's the other address?",
|
||||
"email_title": "Use my email address",
|
||||
"err_bad_detail": "Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.",
|
||||
"err_bad_title": "That didn't work",
|
||||
"err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
|
||||
"err_cert_title": "Couldn't verify {host}",
|
||||
"err_code_expired_detail": "Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.",
|
||||
"err_code_expired_title": "That code has expired",
|
||||
"err_generic_detail": "Something went wrong. Try again.",
|
||||
"err_generic_title": "Sign-in failed",
|
||||
"err_interrupted_detail": "The response didn't match the request we started. Try signing in again.",
|
||||
"err_interrupted_title": "Sign-in was interrupted",
|
||||
"err_no_server_detail": "Double-check the address, or scan a sign-in code from the webmail instead.",
|
||||
"err_no_server_title": "No mail server at {host}",
|
||||
"err_no_webmail_detail": "This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.",
|
||||
"err_no_webmail_title": "No sign-in page at {host}",
|
||||
"err_rate_detail": "The server asked us to wait a moment. Try again shortly.",
|
||||
"err_rate_detail_seconds": "The server asked us to wait. Try again in about {seconds} seconds.",
|
||||
"err_rate_title": "Too many attempts",
|
||||
"err_server_detail": "The server answered with an error. Try again in a few minutes.",
|
||||
"err_server_title": "{host} is having trouble",
|
||||
"err_totp_bad_detail": "Check your password and the current code from your authenticator app, then try again.",
|
||||
"err_totp_detail": "This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.",
|
||||
"err_totp_title": "Enter your two-factor code",
|
||||
"err_unreachable_detail": "Check your connection and the server address, then try again.",
|
||||
"err_unreachable_title": "Can't reach {host}",
|
||||
"know_server": "I know my server address",
|
||||
"known_server_desc": "We already know this server, so there's no setup",
|
||||
"known_server_title": "Another account on {host}",
|
||||
"looking_up": "Looking up your server…",
|
||||
"manual": "Enter server details manually",
|
||||
"notice_bad_qr": "That code isn't a Bulwark sign-in code",
|
||||
"notice_bad_qr_detail": "Open Bulwark on the web, then Settings → Security → Link device to show one.",
|
||||
"notice_bad_server": "That doesn't look like a server address",
|
||||
"notice_bad_server_detail": "Try the address you use for webmail, like mail.example.com.",
|
||||
"notice_full_email": "Enter a full email address",
|
||||
"notice_full_email_detail": "Like ada@example.com. If you sign in with a username instead, use “I know my server address”.",
|
||||
"notice_need_code": "Enter the 6-digit code from your authenticator app",
|
||||
"notice_need_credentials": "Enter your email and password",
|
||||
"notice_need_server": "We need a server address first",
|
||||
"open_settings": "Open Settings",
|
||||
"opening": "Opening {host}",
|
||||
"opening_detail": "Finish signing in on the page that just opened. We’ll take it from there.",
|
||||
"pairing": "Redeeming your sign-in code",
|
||||
"pairing_detail": "Pairing this device with {host}…",
|
||||
"password_subtitle": "Your password is sent to this server only, and stored in the device keychain.",
|
||||
"password_title": "Sign in to {host}",
|
||||
"provider_detail": "You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time.",
|
||||
"provider_title": "Your password stays with your provider",
|
||||
"qr_hint": "Settings → Security → Link device shows a code. Point the camera at it.",
|
||||
"qr_hint_title": "Open Bulwark on your computer",
|
||||
"qr_title": "Sign-in code",
|
||||
"scan_desc": "The fastest way in if you're signed in on the web",
|
||||
"scan_title": "Scan a sign-in code",
|
||||
"server_hint": "Same address you type in the browser. We'll add https:// for you.",
|
||||
"server_label": "Server address",
|
||||
"server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
|
||||
"server_scan_hint": "If you're not sure, scan a sign-in code instead. It carries the server address.",
|
||||
"server_subtitle": "Enter the address you use to reach your webmail.",
|
||||
"server_title": "What's your server address?",
|
||||
"the_server": "the server",
|
||||
"unencrypted": "Unencrypted connection",
|
||||
"use_password": "Sign in with a password instead",
|
||||
"username_label": "Email or username",
|
||||
"your_server": "your server"
|
||||
}
|
||||
},
|
||||
"offline": {
|
||||
"banner": "You are offline",
|
||||
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
|
||||
},
|
||||
"push_prompt": {
|
||||
"body": "Turn on background notifications so new messages reach you while the app is closed.",
|
||||
"dismiss": "Not now",
|
||||
"enable": "Enable",
|
||||
"title": "Get notified about new mail"
|
||||
},
|
||||
"scheduled": {
|
||||
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
|
||||
"cancel_failed": "Cancel failed",
|
||||
"edit_failed": "Could not open the message for editing",
|
||||
"keep_scheduled": "Keep scheduled",
|
||||
"reschedule_failed": "Reschedule failed",
|
||||
"send_now_failed": "Could not send now"
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"accounts": {
|
||||
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
|
||||
"limit": "Maximum of {count} accounts reached."
|
||||
},
|
||||
"shared_accounts": {
|
||||
"description_mobile": "Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail."
|
||||
},
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"advanced": {
|
||||
"about": {
|
||||
"deprecated_update": "Update required: {version}",
|
||||
"mobile_title": "Bulwark Mobile",
|
||||
"security_update": "Security update: {version}",
|
||||
"update_available": "Update: {version}",
|
||||
"update_store_hint": "available in TestFlight / App Store"
|
||||
},
|
||||
"export_settings": {
|
||||
"saved_to": "Saved to {path}"
|
||||
},
|
||||
"reset_settings": {
|
||||
"done": "All settings have been restored to their defaults."
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"language": {
|
||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||
"system_default": "System default"
|
||||
},
|
||||
"toolbar_labels": {
|
||||
"description_mobile": "Display text labels next to toolbar icons."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"experimental": "Experimental",
|
||||
"not_implemented": "Not implemented",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Template used when saving or sharing an attachment."
|
||||
},
|
||||
"email_template": {
|
||||
"description_mobile": "Template used when exporting a message as an .eml file."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"description_mobile": "Convert accented letters to plain ASCII (ä → a)."
|
||||
},
|
||||
"transform_description": "Applied to every exported filename.",
|
||||
"transform_title": "Filename Transform"
|
||||
},
|
||||
"email_behavior": {
|
||||
"archive_mode": {
|
||||
"no_archive": "No archive folder found.",
|
||||
"reorganize_result": "Moved {moved} of {total} emails.",
|
||||
"reorganizing": "Reorganizing…"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"description_mobile": "What happens when you tap an attachment."
|
||||
},
|
||||
"auto_select_reply_identity": {
|
||||
"description_mobile": "When replying, send from the address the message was originally sent to."
|
||||
},
|
||||
"plain_text_mode": {
|
||||
"description_mobile": "Compose and read in plain text only."
|
||||
},
|
||||
"send_delay": {
|
||||
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
|
||||
},
|
||||
"swipe_actions": {
|
||||
"move": "Move to folder",
|
||||
"pin": "Pin"
|
||||
},
|
||||
"swipe_left_action": {
|
||||
"description_mobile": "Action when you drag a row from its right edge towards the left.",
|
||||
"label_mobile": "Swipe left (right → left)"
|
||||
},
|
||||
"swipe_right_action": {
|
||||
"description_mobile": "Action when you drag a row from its left edge towards the right.",
|
||||
"label_mobile": "Swipe right (left → right)"
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"title": "Experimental Feature"
|
||||
},
|
||||
"folders": {
|
||||
"account": "Account",
|
||||
"cannot_delete": "Cannot delete",
|
||||
"description_mobile": "Create, rename, move and delete folders. Long-press a folder in the drawer for quick actions.",
|
||||
"edit_folder": "Edit \"{name}\"",
|
||||
"folder_name": "Folder name",
|
||||
"move_under": "Move under",
|
||||
"name_placeholder": "Receipts",
|
||||
"name_required": "Name required",
|
||||
"new_folder": "New folder",
|
||||
"not_empty_message": "\"{name}\" contains {count} emails. Delete anyway?",
|
||||
"not_empty_title": "Folder not empty",
|
||||
"own_account": "My folders",
|
||||
"role": "Special use",
|
||||
"role_hint": "Assign a special-use role (Archive, Junk, …) to this folder. Each role can be held by one folder.",
|
||||
"save_failed": "Save failed",
|
||||
"system_folder_delete": "System folders cannot be removed.",
|
||||
"system_folder_rename": "System folders cannot be renamed.",
|
||||
"top_level": "Top level"
|
||||
},
|
||||
"identities": {
|
||||
"cannot_delete": "The primary identity cannot be removed.",
|
||||
"cannot_delete_title": "Cannot delete",
|
||||
"count": "{count, plural, =0 {No identities} one {# identity} other {# identities}}",
|
||||
"create": "New identity",
|
||||
"delete_confirm": "Remove \"{name}\"?",
|
||||
"delete_failed": "Delete failed",
|
||||
"delete_title": "Delete identity",
|
||||
"description_mobile": "Manage sender names, email addresses, and signatures. Tap a row to edit.",
|
||||
"display_name": "Display name",
|
||||
"edit": "Edit identity",
|
||||
"email_address": "Email address",
|
||||
"email_locked": "JMAP does not allow changing an identity's email; create a new one instead.",
|
||||
"invalid_email": "Enter a valid email address for this identity.",
|
||||
"invalid_email_title": "Invalid email",
|
||||
"new": "New",
|
||||
"no_name": "(no name)",
|
||||
"primary": "primary",
|
||||
"save_failed": "Save failed",
|
||||
"text_signature": "Plain-text signature"
|
||||
},
|
||||
"keywords": {
|
||||
"description_mobile": "Colored tags to organize your mail.",
|
||||
"discover": {
|
||||
"add_all": "Add all",
|
||||
"found": "{count} tags found that are not defined here.",
|
||||
"none": "No unknown tags found in {scanned} messages.",
|
||||
"partial": "(The scan stopped at the newest messages only.)",
|
||||
"progress": "Scanned {scanned} of {total} messages…",
|
||||
"scan": "Find tags in mailbox"
|
||||
},
|
||||
"reset_confirm": "Replace your tag list with the default tags? Tags already set on messages stay on the server.",
|
||||
"reset_defaults": "Reset to Defaults"
|
||||
},
|
||||
"layout": {
|
||||
"description": "Tune the email list interactions for mobile.",
|
||||
"quick_actions": {
|
||||
"description": "The three buttons shown between Prev/Next at the bottom of an open email. Defaults to Reply, Reply All and Forward.",
|
||||
"label": "Reader quick actions",
|
||||
"mark_unread": "Mark Read/Unread",
|
||||
"relocated": "{count, plural, one {{actions} is moved to the top toolbar so you can still use it.} other {{actions} are moved to the top toolbar so you can still use them.}}",
|
||||
"slot": "Slot {n}",
|
||||
"spam": "Spam",
|
||||
"star": "Star",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"show_avatars_in_junk": "Show sender avatars in Junk",
|
||||
"show_avatars_in_junk_description": "Load sender logos for messages in the Junk folder. Off keeps spam senders from being contacted.",
|
||||
"show_folder_total_count": "Show total message count on folders",
|
||||
"show_folder_total_count_description": "Show the total next to the unread count in the folder list.",
|
||||
"swipe_mode": {
|
||||
"instant": "Instant (swipe = action)",
|
||||
"reveal": "Reveal (swipe, then tap)"
|
||||
},
|
||||
"unified_cross_account": "Unified views across accounts",
|
||||
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
|
||||
},
|
||||
"message_list_order": {
|
||||
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
|
||||
"preset_chronological": "Newest first",
|
||||
"preset_custom": "Custom",
|
||||
"preset_starred_first": "Starred first",
|
||||
"preset_tagged_first": "Tagged first",
|
||||
"preset_unread_first": "Unread first",
|
||||
"scope": "Apply to",
|
||||
"scope_all": "All folders",
|
||||
"scope_inbox": "Inbox only",
|
||||
"tag": "Tag",
|
||||
"title": "Message list order"
|
||||
},
|
||||
"notifications": {
|
||||
"email": {
|
||||
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"phase_account": "Account",
|
||||
"phase_jmap": "Mail server subscription",
|
||||
"phase_permission": "Notification permission",
|
||||
"phase_platform": "Platform",
|
||||
"phase_relay": "Relay registration",
|
||||
"phase_token": "Device token (Firebase)",
|
||||
"phase_verify": "Verification",
|
||||
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
|
||||
"relay_invalid": "Enter a valid https:// URL",
|
||||
"setup_failed": "Setup failed",
|
||||
"status_android_only": "Background notifications are available on Android only."
|
||||
}
|
||||
},
|
||||
"offline": {
|
||||
"cached_count": "{count, plural, one {# message} other {# messages}}",
|
||||
"clear": "Clear",
|
||||
"clear_cache": "Clear cache",
|
||||
"clear_message": "Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?",
|
||||
"clear_title": "Clear offline mail",
|
||||
"days_ago": "{count} d ago",
|
||||
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
|
||||
"downloading": "Downloading {completed}/{total}…",
|
||||
"enabled": "Cache recent mail",
|
||||
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
|
||||
"hours_ago": "{count} h ago",
|
||||
"just_now": "just now",
|
||||
"last_sync": "Last sync {when}",
|
||||
"last_sync_failed": "Last sync failed: {message}",
|
||||
"max_size": "Maximum size",
|
||||
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
|
||||
"minutes_ago": "{count} min ago",
|
||||
"never": "never",
|
||||
"nothing_cached": "Nothing cached yet",
|
||||
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
|
||||
"scanning": "Scanning recent mail…",
|
||||
"sync_now": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"title": "Offline mail",
|
||||
"window": "Window",
|
||||
"window_24h": "Last 24 hours",
|
||||
"window_days": "Last {count} days",
|
||||
"window_desc": "How far back to cache, measured by message receipt date."
|
||||
},
|
||||
"plugins": {
|
||||
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
|
||||
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
|
||||
},
|
||||
"security": {
|
||||
"client_cert": {
|
||||
"active": "Active: {alias}",
|
||||
"choose_another": "Choose another",
|
||||
"clear": "Clear",
|
||||
"clear_message": "New requests will no longer present a certificate. The certificate stays installed in Android.",
|
||||
"clear_title": "Stop using client certificate?",
|
||||
"description": "Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.",
|
||||
"hint": "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.",
|
||||
"not_set": "Not set",
|
||||
"pick": "Pick certificate",
|
||||
"pick_failed": "Pick failed",
|
||||
"title": "TLS client certificate"
|
||||
},
|
||||
"credentials": {
|
||||
"allowed_ips": "Allowed IPs (optional)",
|
||||
"allowed_ips_hint": "Comma or space separated. Leave empty to allow any IP.",
|
||||
"clear_expiry": "Clear expiry",
|
||||
"copy_now": "Copy this now — it is shown only once. Tap and hold to select.",
|
||||
"create_error": "Could not create",
|
||||
"expires": "Expires (optional)",
|
||||
"expires_on": "expires {date}",
|
||||
"no_expiry": "No expiry",
|
||||
"none": "None yet.",
|
||||
"remove_error": "Could not remove",
|
||||
"remove_message": "Any client using it will stop working.",
|
||||
"remove_title": "Remove credential?"
|
||||
},
|
||||
"display_name": {
|
||||
"error_title": "Update failed"
|
||||
},
|
||||
"encryption": {
|
||||
"no_keys": "Add a public key first - encryption at rest needs one."
|
||||
},
|
||||
"load_error": "Failed to load security settings.",
|
||||
"not_supported": "Account security management requires a Stalwart server. These features are not available for this account.",
|
||||
"offline": "You are offline. Account security settings need a live connection to the server.",
|
||||
"password": {
|
||||
"change": "Change Password",
|
||||
"changed": "Your account password was updated.",
|
||||
"changed_title": "Password changed",
|
||||
"description": "Change the password for this account.",
|
||||
"error": "Failed to change password.",
|
||||
"mismatch": "Passwords do not match.",
|
||||
"toggle_visibility": "Show or hide password",
|
||||
"too_short": "New password must be at least 8 characters."
|
||||
},
|
||||
"public_keys": {
|
||||
"add_error": "Failed to add public key",
|
||||
"description": "S/MIME certificates and PGP keys stored on the server. They are used for encryption at rest.",
|
||||
"expires": "expires {date}",
|
||||
"remove_error": "Failed to remove public key",
|
||||
"remove_message": "Encryption at rest that uses this key will stop working.",
|
||||
"remove_title": "Remove public key?",
|
||||
"title": "Public Keys"
|
||||
},
|
||||
"two_factor": {
|
||||
"active": "Active",
|
||||
"code": "Verification code",
|
||||
"code_required": "Enter the 6-digit code from your app.",
|
||||
"description": "Require a one-time code at login.",
|
||||
"disable": "Disable",
|
||||
"disable_error": "Failed to disable two-factor authentication.",
|
||||
"disable_hint": "Enter your password to turn off two-factor authentication.",
|
||||
"enable_error": "Failed to enable two-factor authentication.",
|
||||
"enabled_message": "You will be asked for a code at next sign-in.",
|
||||
"enabled_title": "Two-factor enabled",
|
||||
"enrol_hint": "Add this secret to your authenticator app, then enter the 6-digit code to confirm.",
|
||||
"inactive": "Inactive",
|
||||
"label": "Authenticator app",
|
||||
"manual_secret": "Or enter this secret manually",
|
||||
"no_app": "Could not open an authenticator app. Add the secret below manually.",
|
||||
"no_app_title": "No authenticator app",
|
||||
"open_app": "Add to authenticator app",
|
||||
"password_required": "Enter your current password.",
|
||||
"section_title": "Two-Factor Authentication"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"add": "Add app",
|
||||
"empty": "No apps added yet.",
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"name_placeholder": "App name",
|
||||
"open_mode": "Open mode",
|
||||
"show_on_mobile": "Show on mobile",
|
||||
"url": "URL"
|
||||
},
|
||||
"keep_loaded_description_mobile": "Keep inline apps running in the background when switching away.",
|
||||
"open_mode": {
|
||||
"inline": "Inline",
|
||||
"new_tab": "New tab",
|
||||
"tab": "Tab"
|
||||
}
|
||||
},
|
||||
"smime": {
|
||||
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
|
||||
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"templates": {
|
||||
"delete_title": "Delete template"
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "The default light and dark palettes.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
|
||||
"title": "Themes"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"account_error": "Needs attention",
|
||||
"remove_account_hint": "Long-press to remove this account",
|
||||
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
|
||||
"remove_account_title": "Remove account?",
|
||||
"show_unread": "Show unread"
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"accounts_failed": "{count} account(s) could not be loaded"
|
||||
},
|
||||
"updates": {
|
||||
"advisory": "Security advisory",
|
||||
"advisory_desc": "Details about the issue this release fixes.",
|
||||
"auto_check": "Check automatically",
|
||||
"auto_check_desc": "Check for updates every few hours when the app starts.",
|
||||
"available": "Update available",
|
||||
"available_desc": "Tap install to download v{version} and open it with Android's package installer.",
|
||||
"banner": {
|
||||
"advisory": "Read the advisory",
|
||||
"deprecated_subtitle": "Install v{version} to keep using the app.",
|
||||
"deprecated_title": "This version is no longer supported",
|
||||
"security_subtitle": "v{version} fixes a security issue. Install it as soon as possible.",
|
||||
"security_title": "Security update available",
|
||||
"subtitle": "v{version} is ready to install.",
|
||||
"title": "Update available"
|
||||
},
|
||||
"building": "Update building",
|
||||
"building_desc": "v{version} was published, but the APK is still being built. This page will check again every 30 seconds.",
|
||||
"check": "Check for updates",
|
||||
"check_again": "Check again",
|
||||
"check_now": "Check now",
|
||||
"checking": "Checking…",
|
||||
"current_version": "Current version",
|
||||
"description": "Check for new versions and install them.",
|
||||
"downloading": "Downloading…",
|
||||
"downloading_pct": "Downloading {pct}%",
|
||||
"install": "Install",
|
||||
"installing": "Installing…",
|
||||
"last_checked": "Last checked",
|
||||
"last_error": "Last error: {message}",
|
||||
"latest_version": "Latest version",
|
||||
"never": "never",
|
||||
"open": "Open",
|
||||
"open_github": "Open on GitHub",
|
||||
"opening_installer": "Opening installer…",
|
||||
"progress_of": "{written} of {total}",
|
||||
"published": "Published {date}",
|
||||
"rate_limited": "GitHub is rate-limiting update checks; trying again later.",
|
||||
"release_notes": "Release notes",
|
||||
"release_page": "Release page",
|
||||
"release_page_desc": "Full release notes and downloads on GitHub.",
|
||||
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
|
||||
"severity_deprecated": "Required",
|
||||
"severity_security": "Security",
|
||||
"title": "App updates"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+856
-92
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+857
-93
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+855
-91
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
||||
<title>Bulwark Mobile — Sign-in redesign</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3z" /><path d="M21 14v3M21 21h-4" />
|
||||
</symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m2 7 10 6 10-6" />
|
||||
</symbol>
|
||||
<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m4 12 5 5L20 6" />
|
||||
</symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M12 7v6M12 16.5v.5" />
|
||||
</symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 6 18 18M18 6 6 18" />
|
||||
</symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" />
|
||||
</symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3 5 6v6c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5" /><path d="m10.8 12.2 8-8M17 6l2.5 2.5M14.5 8.5 17 11" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--page-bg: #fbfbfc;
|
||||
--page-panel: #f0f3f8;
|
||||
--page-ink: #14171f;
|
||||
--page-ink-2: #545c6b;
|
||||
--page-ink-3: #8a919f;
|
||||
--page-rule: #e1e6ef;
|
||||
--accent: #2f6fed;
|
||||
--accent-soft: rgba(47, 111, 237, 0.09);
|
||||
|
||||
--app-bg: #ffffff;
|
||||
--app-surface: #f8fafc;
|
||||
--app-surface-2: #f1f5f9;
|
||||
--app-border: #e2e8f0;
|
||||
--app-text: #0f172a;
|
||||
--app-text-2: #64748b;
|
||||
--app-muted: #94a3b8;
|
||||
--app-primary: #3b82f6;
|
||||
--app-primary-bg: #dbeafe;
|
||||
--app-error: #ef4444;
|
||||
--app-error-bg: #fee2e2;
|
||||
--app-success: #16a34a;
|
||||
|
||||
--frame: #d5dae5;
|
||||
--frame-shadow: rgba(20, 26, 40, 0.14);
|
||||
|
||||
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
|
||||
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--page-bg: #0a0b0d;
|
||||
--page-panel: #111319;
|
||||
--page-ink: #eef1f6;
|
||||
--page-ink-2: #a4adbb;
|
||||
--page-ink-3: #6e7684;
|
||||
--page-rule: #22262f;
|
||||
--accent: #74aaff;
|
||||
--accent-soft: rgba(116, 170, 255, 0.12);
|
||||
|
||||
--app-bg: #09090b;
|
||||
--app-surface: #18181b;
|
||||
--app-surface-2: #1f1f23;
|
||||
--app-border: #27272a;
|
||||
--app-text: #fafafa;
|
||||
--app-text-2: #a1a1aa;
|
||||
--app-muted: #71717a;
|
||||
--app-primary: #3b82f6;
|
||||
--app-primary-bg: #172554;
|
||||
--app-error: #ef4444;
|
||||
--app-error-bg: #450a0a;
|
||||
--app-success: #22c55e;
|
||||
|
||||
--frame: #2b303a;
|
||||
--frame-shadow: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--page-bg: #fbfbfc; --page-panel: #f0f3f8; --page-ink: #14171f; --page-ink-2: #545c6b;
|
||||
--page-ink-3: #8a919f; --page-rule: #e1e6ef; --accent: #2f6fed; --accent-soft: rgba(47,111,237,0.09);
|
||||
--app-bg: #ffffff; --app-surface: #f8fafc; --app-surface-2: #f1f5f9; --app-border: #e2e8f0;
|
||||
--app-text: #0f172a; --app-text-2: #64748b; --app-muted: #94a3b8; --app-primary: #3b82f6;
|
||||
--app-primary-bg: #dbeafe; --app-error: #ef4444; --app-error-bg: #fee2e2; --app-success: #16a34a;
|
||||
--frame: #d5dae5; --frame-shadow: rgba(20,26,40,0.14);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--page-bg: #0a0b0d; --page-panel: #111319; --page-ink: #eef1f6; --page-ink-2: #a4adbb;
|
||||
--page-ink-3: #6e7684; --page-rule: #22262f; --accent: #74aaff; --accent-soft: rgba(116,170,255,0.12);
|
||||
--app-bg: #09090b; --app-surface: #18181b; --app-surface-2: #1f1f23; --app-border: #27272a;
|
||||
--app-text: #fafafa; --app-text-2: #a1a1aa; --app-muted: #71717a; --app-primary: #3b82f6;
|
||||
--app-primary-bg: #172554; --app-error: #ef4444; --app-error-bg: #450a0a; --app-success: #22c55e;
|
||||
--frame: #2b303a; --frame-shadow: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--page-bg);
|
||||
color: var(--page-ink);
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.d-wrap { max-width: 1180px; margin: 0 auto; padding: 64px 28px 96px; }
|
||||
|
||||
/* ---------- document typography ---------- */
|
||||
.d-eyebrow {
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--page-ink-3); margin: 0 0 18px;
|
||||
}
|
||||
.d-title {
|
||||
font-family: var(--sans); font-weight: 700; font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.04; letter-spacing: -0.028em; margin: 0; text-wrap: balance;
|
||||
}
|
||||
.d-deck {
|
||||
max-width: 62ch; margin: 22px 0 0; font-size: 19px; line-height: 1.62; color: var(--page-ink-2);
|
||||
}
|
||||
.d-deck strong { color: var(--page-ink); font-weight: 600; }
|
||||
|
||||
.d-section { margin-top: 84px; }
|
||||
.d-h2 {
|
||||
font-family: var(--sans); font-weight: 700; font-size: 25px; letter-spacing: -0.018em;
|
||||
margin: 0; text-wrap: balance;
|
||||
}
|
||||
.d-lede { max-width: 62ch; margin: 12px 0 0; color: var(--page-ink-2); }
|
||||
.d-rule { height: 1px; background: var(--page-rule); border: 0; margin: 26px 0 34px; }
|
||||
|
||||
.d-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 10px; margin-top: 30px;
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-2);
|
||||
}
|
||||
.d-meta span {
|
||||
border: 1px solid var(--page-rule); border-radius: 999px; padding: 4px 11px; background: var(--page-panel);
|
||||
}
|
||||
|
||||
/* ---------- comparison ---------- */
|
||||
.d-compare {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px;
|
||||
background: var(--page-rule); border: 1px solid var(--page-rule); border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.d-cell { background: var(--page-bg); padding: 20px 22px; }
|
||||
.d-cell dt {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--page-ink-3); margin: 0 0 10px;
|
||||
}
|
||||
.d-cell dd { margin: 0; font-family: var(--sans); font-size: 15px; line-height: 1.45; }
|
||||
.d-was { color: var(--page-ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
|
||||
.d-now { color: var(--page-ink); font-weight: 600; display: block; margin-top: 3px; }
|
||||
|
||||
/* ---------- filmstrip ---------- */
|
||||
.d-strip {
|
||||
display: flex; gap: 30px; overflow-x: auto; padding: 30px 26px 26px;
|
||||
background: var(--page-panel); border-radius: 16px; scroll-snap-type: x proximity;
|
||||
}
|
||||
.d-strip figure { margin: 0; scroll-snap-align: center; flex: 0 0 auto; width: 288px; }
|
||||
.d-strip figcaption { margin-top: 18px; }
|
||||
.d-step {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--accent); display: block; margin-bottom: 5px;
|
||||
}
|
||||
.d-step.is-alt { color: var(--page-ink-3); }
|
||||
.d-cap-title { font-family: var(--sans); font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
|
||||
.d-cap-body { font-size: 14.5px; line-height: 1.5; color: var(--page-ink-2); margin: 5px 0 0; }
|
||||
|
||||
/* ---------- notes ---------- */
|
||||
.d-notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 22px; max-width: 74ch; }
|
||||
.d-notes li { display: grid; grid-template-columns: 26px 1fr; gap: 14px; align-items: start; }
|
||||
.d-notes svg { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
|
||||
.d-note-h { font-family: var(--sans); font-weight: 650; font-size: 15.5px; }
|
||||
.d-note-b { color: var(--page-ink-2); font-size: 16px; }
|
||||
code {
|
||||
font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft);
|
||||
color: var(--accent); padding: 1.5px 5px; border-radius: 4px;
|
||||
}
|
||||
|
||||
.d-foot {
|
||||
margin-top: 80px; padding-top: 22px; border-top: 1px solid var(--page-rule);
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-3);
|
||||
}
|
||||
|
||||
/* ================= phone chrome ================= */
|
||||
.p {
|
||||
width: 288px; height: 600px; border-radius: 36px; background: var(--frame);
|
||||
padding: 8px; box-shadow: 0 18px 44px -18px var(--frame-shadow); position: relative;
|
||||
}
|
||||
.p-screen {
|
||||
width: 100%; height: 100%; border-radius: 29px; background: var(--app-bg); overflow: hidden;
|
||||
position: relative; font-family: var(--sans); color: var(--app-text); font-size: 14px; line-height: 1.4;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.p-notch {
|
||||
position: absolute; top: 15px; left: 50%; transform: translateX(-50%); width: 74px; height: 5px;
|
||||
border-radius: 99px; background: var(--app-border); z-index: 6;
|
||||
}
|
||||
.p-pad { padding: 42px 24px 22px; flex: 1; display: flex; flex-direction: column; }
|
||||
.p-pad.is-centered { justify-content: center; }
|
||||
|
||||
/* ---- in-app primitives ---- */
|
||||
.s-logo {
|
||||
width: 56px; height: 56px; border-radius: 17px; background: var(--app-primary);
|
||||
display: grid; place-items: center; margin: 0 auto 16px; color: #fff;
|
||||
}
|
||||
.s-logo svg { width: 30px; height: 30px; }
|
||||
.s-brand { text-align: center; margin-bottom: 30px; }
|
||||
.s-brand h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
|
||||
.s-brand p { font-size: 12.5px; color: var(--app-muted); margin: 5px 0 0; }
|
||||
|
||||
.s-h { font-size: 21px; font-weight: 700; letter-spacing: -0.022em; margin: 0; line-height: 1.22; }
|
||||
.s-sub { font-size: 13px; color: var(--app-text-2); margin: 8px 0 0; line-height: 1.45; }
|
||||
|
||||
.s-tile {
|
||||
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
|
||||
border: 1px solid var(--app-border); background: var(--app-surface);
|
||||
border-radius: 14px; padding: 14px 15px; color: inherit;
|
||||
}
|
||||
.s-tile.is-primary { background: var(--app-primary); border-color: var(--app-primary); color: #fff; }
|
||||
.s-tile-ico { width: 22px; height: 22px; flex: 0 0 22px; opacity: 0.95; }
|
||||
.s-tile-txt { flex: 1; min-width: 0; }
|
||||
.s-tile-t { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
|
||||
.s-tile-d { font-size: 11.5px; opacity: 0.72; margin-top: 2px; }
|
||||
.s-tile-chev { width: 16px; height: 16px; opacity: 0.5; }
|
||||
|
||||
.s-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.s-field { margin-top: 20px; }
|
||||
.s-label { font-size: 12px; font-weight: 600; color: var(--app-text-2); margin-bottom: 7px; }
|
||||
.s-input {
|
||||
border: 1px solid var(--app-border); background: var(--app-bg); border-radius: 10px;
|
||||
height: 42px; padding: 0 13px; display: flex; align-items: center; gap: 8px; font-size: 14px;
|
||||
}
|
||||
.s-input.is-focus { border-color: var(--app-primary); box-shadow: 0 0 0 3px var(--app-primary-bg); }
|
||||
.s-input.is-error { border-color: var(--app-error); }
|
||||
.s-input .s-ph { color: var(--app-muted); }
|
||||
.s-input svg { width: 17px; height: 17px; color: var(--app-muted); flex: 0 0 17px; }
|
||||
.s-caret {
|
||||
width: 1.5px; height: 17px; background: var(--app-primary); display: inline-block;
|
||||
animation: blink 1.05s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .s-caret { animation: none; } }
|
||||
|
||||
.s-hint { font-size: 11.5px; color: var(--app-muted); margin-top: 8px; line-height: 1.45; }
|
||||
.s-hint.is-error { color: var(--app-error); }
|
||||
|
||||
.s-btn {
|
||||
height: 46px; border-radius: 12px; background: var(--app-primary); color: #fff;
|
||||
display: grid; place-items: center; font-size: 15px; font-weight: 600; margin-top: 20px;
|
||||
}
|
||||
.s-btn.is-off { background: var(--app-surface-2); color: var(--app-muted); }
|
||||
.s-btn.is-quiet {
|
||||
background: transparent; color: var(--app-text); border: 1px solid var(--app-border); font-weight: 500;
|
||||
}
|
||||
.s-link { text-align: center; font-size: 13px; color: var(--app-primary); margin-top: 16px; font-weight: 500; }
|
||||
.s-link.is-muted { color: var(--app-muted); font-weight: 400; }
|
||||
|
||||
.s-back { width: 22px; height: 22px; color: var(--app-text-2); margin-bottom: 22px; }
|
||||
.s-foot {
|
||||
text-align: center; font-size: 11px; color: var(--app-muted); padding: 0 0 8px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.s-card {
|
||||
border: 1px solid var(--app-border); background: var(--app-surface); border-radius: 14px;
|
||||
padding: 15px; display: flex; align-items: center; gap: 12px; margin-top: 22px;
|
||||
}
|
||||
.s-card-ico {
|
||||
width: 38px; height: 38px; border-radius: 11px; background: var(--app-primary-bg);
|
||||
color: var(--app-primary); display: grid; place-items: center; flex: 0 0 38px;
|
||||
}
|
||||
.s-card-ico svg { width: 19px; height: 19px; }
|
||||
.s-card-t { font-size: 14.5px; font-weight: 600; }
|
||||
.s-card-d { font-size: 11.5px; color: var(--app-text-2); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
|
||||
.s-dot { width: 5px; height: 5px; border-radius: 99px; background: var(--app-success); display: inline-block; }
|
||||
|
||||
.s-note {
|
||||
display: flex; gap: 9px; align-items: flex-start; margin-top: 16px;
|
||||
font-size: 11.5px; color: var(--app-text-2); line-height: 1.5;
|
||||
}
|
||||
.s-note svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-muted); }
|
||||
|
||||
.s-banner {
|
||||
display: flex; gap: 9px; align-items: flex-start; border-radius: 11px; padding: 11px 12px;
|
||||
background: var(--app-error-bg); font-size: 12px; line-height: 1.45; margin-top: 18px;
|
||||
}
|
||||
.s-banner svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-error); }
|
||||
|
||||
.s-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
|
||||
.s-divider::before, .s-divider::after { content: ""; height: 1px; flex: 1; background: var(--app-border); }
|
||||
.s-divider span { font-size: 11.5px; color: var(--app-muted); }
|
||||
|
||||
/* progress rows (setting up) */
|
||||
.s-prog { display: flex; align-items: center; gap: 11px; padding: 11px 0; font-size: 13.5px; }
|
||||
.s-prog .s-tick {
|
||||
width: 20px; height: 20px; border-radius: 99px; background: var(--app-success);
|
||||
color: #fff; display: grid; place-items: center; flex: 0 0 20px;
|
||||
}
|
||||
.s-prog .s-tick svg { width: 11px; height: 11px; }
|
||||
.s-prog .s-spin {
|
||||
width: 20px; height: 20px; border-radius: 99px; flex: 0 0 20px;
|
||||
border: 2px solid var(--app-border); border-top-color: var(--app-primary); animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .s-prog .s-spin { animation: none; } }
|
||||
.s-prog.is-pending { color: var(--app-muted); }
|
||||
|
||||
/* account rows (add mode) */
|
||||
.s-acct { display: flex; align-items: center; gap: 11px; padding: 11px 0; }
|
||||
.s-av {
|
||||
width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center;
|
||||
font-size: 13px; font-weight: 600; color: #fff; flex: 0 0 34px;
|
||||
}
|
||||
.s-acct-t { font-size: 14px; font-weight: 550; }
|
||||
.s-acct-d { font-size: 11.5px; color: var(--app-muted); }
|
||||
|
||||
/* scanner — deliberately always dark, it's a camera view */
|
||||
.p-screen.is-camera { background: #0b0d10; color: #fff; }
|
||||
.s-cam { position: absolute; inset: 0; background:
|
||||
radial-gradient(120% 80% at 50% 30%, #2a3038 0%, #14171c 55%, #0b0d10 100%); }
|
||||
.s-cam-grid { position: absolute; inset: 0; opacity: 0.18; background-image:
|
||||
linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 34px 34px; }
|
||||
.s-reticle { position: absolute; top: 168px; left: 50%; transform: translateX(-50%); width: 176px; height: 176px; }
|
||||
.s-reticle i {
|
||||
position: absolute; width: 34px; height: 34px; border: 3px solid #fff; border-radius: 4px;
|
||||
}
|
||||
.s-reticle i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
|
||||
.s-reticle i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
|
||||
.s-reticle i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
|
||||
.s-reticle i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
|
||||
.s-qr {
|
||||
position: absolute; inset: 22px; border-radius: 6px; background: #fff;
|
||||
display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(9, 1fr); padding: 8px; gap: 2px;
|
||||
}
|
||||
.s-qr b { background: #0b0d10; border-radius: 1px; }
|
||||
.s-cam-top {
|
||||
position: absolute; top: 40px; left: 0; right: 0; display: flex; align-items: center;
|
||||
justify-content: space-between; padding: 0 20px; z-index: 3;
|
||||
}
|
||||
.s-cam-top svg { width: 22px; height: 22px; color: #fff; }
|
||||
.s-cam-copy { position: absolute; left: 0; right: 0; bottom: 46px; text-align: center; padding: 0 30px; z-index: 3; }
|
||||
.s-cam-copy p { margin: 0; font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.82); }
|
||||
.s-cam-copy .s-cam-h { font-size: 15.5px; font-weight: 600; color: #fff; margin-bottom: 6px; }
|
||||
.s-cam-copy .s-cam-link { margin-top: 18px; font-size: 13px; font-weight: 500; color: #8ab4ff; }
|
||||
|
||||
/* browser handoff sheet */
|
||||
.s-sheet {
|
||||
position: absolute; inset: 26px 0 0; background: var(--app-bg); border-radius: 18px 18px 0 0;
|
||||
box-shadow: 0 -8px 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.s-sheet-bar {
|
||||
display: flex; align-items: center; gap: 9px; padding: 13px 15px;
|
||||
border-bottom: 1px solid var(--app-border); font-size: 12.5px; color: var(--app-text-2);
|
||||
}
|
||||
.s-sheet-bar svg { width: 13px; height: 13px; color: var(--app-success); }
|
||||
.s-sheet-bar .s-url { flex: 1; color: var(--app-text); font-weight: 550; }
|
||||
.s-sheet-bar .s-done { color: var(--app-primary); font-weight: 600; }
|
||||
.s-sheet-body { padding: 30px 24px; flex: 1; }
|
||||
.s-scrim { position: absolute; inset: 0; background: rgba(4,6,10,0.5); }
|
||||
.s-web-brand { display: flex; align-items: center; gap: 9px; margin-bottom: 24px; }
|
||||
.s-web-brand .s-web-mark {
|
||||
width: 28px; height: 28px; border-radius: 9px; background: var(--app-primary);
|
||||
color: #fff; display: grid; place-items: center;
|
||||
}
|
||||
.s-web-brand .s-web-mark svg { width: 16px; height: 16px; }
|
||||
.s-web-brand b { font-size: 14.5px; letter-spacing: -0.01em; }
|
||||
|
||||
/* the "before" screen is intentionally cramped */
|
||||
.s-old-field { margin-bottom: 13px; }
|
||||
</style>
|
||||
|
||||
<div class="d-wrap">
|
||||
|
||||
<p class="d-eyebrow">Bulwark Mobile · sign-in redesign</p>
|
||||
<h1 class="d-title">Three fields nobody knows the answer to</h1>
|
||||
<p class="d-deck">
|
||||
The current sign-in screen opens with a <strong>Server URL</strong> field. Most people don't know their
|
||||
JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit
|
||||
below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with
|
||||
the easy paths and asks for a server only when we genuinely can't work it out.
|
||||
</p>
|
||||
|
||||
<div class="d-meta">
|
||||
<span>src/screens/LoginScreen.tsx</span>
|
||||
<span>expo · react-native</span>
|
||||
<span>light + dark, both shown</span>
|
||||
</div>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">What changes</h2>
|
||||
<hr class="d-rule" />
|
||||
<dl class="d-compare">
|
||||
<div class="d-cell">
|
||||
<dt>To reach the inbox</dt>
|
||||
<dd><span class="d-was">3 fields typed</span><span class="d-now">1 field, or none with a code</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>Server URL</dt>
|
||||
<dd><span class="d-was">Asked first, always</span><span class="d-now">Looked up from the address</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>Password in the app</dt>
|
||||
<dd><span class="d-was">Default path</span><span class="d-now">Fallback only</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>QR sign-in</dt>
|
||||
<dd><span class="d-was">Ghost button, disabled</span><span class="d-now">First option offered</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Today</h2>
|
||||
<p class="d-lede">
|
||||
Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a
|
||||
password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
<p>Secure. Private. Yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Server URL</div>
|
||||
<div class="s-input"><span class="s-ph">https://mail.example.com</span></div>
|
||||
</div>
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Email or Username</div>
|
||||
<div class="s-input"><span class="s-ph">you@example.com</span></div>
|
||||
</div>
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Password</div>
|
||||
<div class="s-input">
|
||||
<span class="s-ph" style="flex:1">Enter your password</span>
|
||||
<svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-btn is-off" style="margin-top:12px">Sign In</div>
|
||||
|
||||
<div class="s-divider"><span>or continue with</span></div>
|
||||
<div class="s-stack">
|
||||
<div class="s-btn is-quiet" style="margin-top:0;opacity:0.45">Sign in via webmail</div>
|
||||
<div class="s-link is-muted" style="margin-top:2px;opacity:0.45">Scan QR code</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Current build</span>
|
||||
<span class="d-cap-title">One screen, six controls</span>
|
||||
<p class="d-cap-body">
|
||||
The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where
|
||||
to find a server URL, and nothing happens if you don't have one.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Proposed flow</h2>
|
||||
<p class="d-lede">
|
||||
One question per screen, cheapest question first. The screens below are the happy path, left to right.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
|
||||
<!-- 1 · welcome -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
<p>Secure. Private. Yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-stack">
|
||||
<div class="s-tile is-primary">
|
||||
<svg class="s-tile-ico"><use href="#i-qr" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Scan a sign-in code</span>
|
||||
<span class="s-tile-d">Already signed in on the web? Fastest way in.</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Use my email address</span>
|
||||
<span class="s-tile-d">We'll find your mail server</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-link is-muted">Enter server details manually</div>
|
||||
</div>
|
||||
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 1</span>
|
||||
<span class="d-cap-title">Pick a way in</span>
|
||||
<p class="d-cap-body">
|
||||
Two live options instead of six mixed ones. Each tile says what it costs you before you tap it.
|
||||
Manual setup stays reachable in one tap — it just isn't the greeting.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 2 · email -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">What's your email address?</h2>
|
||||
<p class="s-sub">That's usually all we need — we'll look up the mail server for you.</p>
|
||||
|
||||
<div class="s-field">
|
||||
<div class="s-input is-focus">
|
||||
<svg><use href="#i-mail" /></svg>
|
||||
<span>ada@example.com</span><span class="s-caret"></span>
|
||||
</div>
|
||||
<p class="s-hint">Works with Bulwark, Stalwart, and any JMAP mail server.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="s-link is-muted">I know my server address</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 2</span>
|
||||
<span class="d-cap-title">One field, keyboard already up</span>
|
||||
<p class="d-cap-body">
|
||||
Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap,
|
||||
so nobody is left staring at a dead grey button wondering which field is wrong.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 3 · found server -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Found your mail server</h2>
|
||||
<p class="s-sub">Next you'll sign in on your provider's own page.</p>
|
||||
|
||||
<div class="s-card">
|
||||
<span class="s-card-ico"><svg><use href="#i-globe" /></svg></span>
|
||||
<span>
|
||||
<span class="s-card-t">mail.example.com</span>
|
||||
<span class="s-card-d"><i class="s-dot"></i> JMAP · secure connection</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="s-note">
|
||||
<svg><use href="#i-lock" /></svg>
|
||||
<span>Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke.</span>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div class="s-link">Use a different server</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 3</span>
|
||||
<span class="d-cap-title">Confirm before the browser opens</span>
|
||||
<p class="d-cap-body">
|
||||
A browser jump with no warning feels like a bug. Naming the server first — and saying why the
|
||||
password isn't asked for here — turns the detour into a security feature.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 4 · handoff -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered" style="opacity:0.5">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-scrim"></div>
|
||||
<div class="s-sheet">
|
||||
<div class="s-sheet-bar">
|
||||
<svg><use href="#i-lock" /></svg>
|
||||
<span class="s-url">mail.example.com</span>
|
||||
<span class="s-done">Done</span>
|
||||
</div>
|
||||
<div class="s-sheet-body">
|
||||
<div class="s-web-brand">
|
||||
<span class="s-web-mark"><svg><use href="#i-shield" /></svg></span>
|
||||
<b>Sign in to Bulwark</b>
|
||||
</div>
|
||||
<div class="s-label">Email</div>
|
||||
<div class="s-input"><span>ada@example.com</span></div>
|
||||
<div class="s-label" style="margin-top:14px">Password</div>
|
||||
<div class="s-input is-focus">
|
||||
<span style="flex:1">••••••••••</span><svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
<div class="s-btn">Sign in</div>
|
||||
<div class="s-divider"><span>or</span></div>
|
||||
<div class="s-tile" style="padding:11px 13px">
|
||||
<svg class="s-tile-ico"><use href="#i-key" /></svg>
|
||||
<span class="s-tile-txt"><span class="s-tile-t">Continue with passkey</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 4</span>
|
||||
<span class="d-cap-title">The provider's page, in a sheet</span>
|
||||
<p class="d-cap-body">
|
||||
This is the existing <code>runWebmailHandoff</code> flow, unchanged. It already supports whatever
|
||||
the server offers — password, OAuth, passkeys, SSO — which the in-app form never will.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 5 · setting up -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div style="text-align:center;margin-bottom:26px">
|
||||
<span class="s-av" style="width:60px;height:60px;font-size:22px;background:var(--app-primary);margin:0 auto 14px">AL</span>
|
||||
<h2 class="s-h" style="font-size:20px">Welcome back, Ada</h2>
|
||||
<p class="s-sub">ada@example.com</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="s-prog">
|
||||
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Mailboxes
|
||||
</div>
|
||||
<div class="s-prog">
|
||||
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Recent mail
|
||||
</div>
|
||||
<div class="s-prog is-pending">
|
||||
<span class="s-spin"></span> Contacts and calendars
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 5</span>
|
||||
<span class="d-cap-title">Land on progress, not a spinner</span>
|
||||
<p class="d-cap-body">
|
||||
The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and
|
||||
the inbox opens as soon as mailboxes land — the rest fills in behind it.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">When it doesn't go smoothly</h2>
|
||||
<p class="d-lede">
|
||||
Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the
|
||||
exception. These four screens carry most of the redesign's weight.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
|
||||
<!-- scanner -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen is-camera">
|
||||
<div class="s-cam"><div class="s-cam-grid"></div></div>
|
||||
<div class="s-cam-top">
|
||||
<svg><use href="#i-x" /></svg>
|
||||
<span style="font-size:12.5px;opacity:0.8">Sign-in code</span>
|
||||
<span style="width:22px"></span>
|
||||
</div>
|
||||
<div class="s-reticle">
|
||||
<i></i><i></i><i></i><i></i>
|
||||
<div class="s-qr">
|
||||
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b><b></b>
|
||||
<b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-cam-copy">
|
||||
<p class="s-cam-h">Open Bulwark on your computer</p>
|
||||
<p>Settings → Devices → Add phone shows a code. Point the camera at it.</p>
|
||||
<p class="s-cam-link">Type the code instead</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Path A</span>
|
||||
<span class="d-cap-title">The scanner tells you where the code is</span>
|
||||
<p class="d-cap-body">
|
||||
A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page.
|
||||
Redeeming a paired code skips the browser entirely — see <code>redeemPairingCode</code>.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- discovery failed -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Where does your mail live?</h2>
|
||||
<p class="s-sub">We couldn't find a server for <b>example.org</b>. If you self-host, the address is the one you use for webmail.</p>
|
||||
|
||||
<div class="s-field">
|
||||
<div class="s-label">Server address</div>
|
||||
<div class="s-input is-focus">
|
||||
<svg><use href="#i-globe" /></svg>
|
||||
<span>mail.example.org</span><span class="s-caret"></span>
|
||||
</div>
|
||||
<p class="s-hint">Same address you type in the browser. We'll add <b>https://</b>.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="s-note">
|
||||
<svg><use href="#i-qr" /></svg>
|
||||
<span>Not sure? Scan a sign-in code from the webmail instead — it carries the address with it.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Path B</span>
|
||||
<span class="d-cap-title">Ask for the server people know</span>
|
||||
<p class="d-cap-body">
|
||||
Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and
|
||||
a trailing slash or full <code>/jmap</code> path is accepted and trimmed.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- password fallback + error -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Sign in to mail.example.org</h2>
|
||||
<p class="s-sub">This server doesn't support the browser sign-in, so we'll ask here.</p>
|
||||
|
||||
<div class="s-field" style="margin-top:18px">
|
||||
<div class="s-label">Email or username</div>
|
||||
<div class="s-input"><span>ada@example.org</span></div>
|
||||
</div>
|
||||
|
||||
<div class="s-field" style="margin-top:14px">
|
||||
<div class="s-label">Password</div>
|
||||
<div class="s-input is-error">
|
||||
<span style="flex:1">••••••••</span><svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-banner">
|
||||
<svg><use href="#i-alert" /></svg>
|
||||
<span><b>That password didn't work.</b> If your account has two-factor sign-in, you'll need an app password from the webmail.</span>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Sign in</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Path C</span>
|
||||
<span class="d-cap-title">Errors that name the likely cause</span>
|
||||
<p class="d-cap-body">
|
||||
The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable
|
||||
host, bad certificate, 2FA-needs-an-app-password. Each says what to do next.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- add account -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<div style="display:flex;justify-content:flex-end"><svg class="s-back" style="margin-bottom:14px"><use href="#i-x" /></svg></div>
|
||||
<h2 class="s-h">Add another account</h2>
|
||||
<p class="s-sub">You're signed in to these already.</p>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<div class="s-acct">
|
||||
<span class="s-av" style="background:#3b82f6">AL</span>
|
||||
<span><span class="s-acct-t">Ada Lovelace</span><span class="s-acct-d">ada@example.com</span></span>
|
||||
</div>
|
||||
<div class="s-acct">
|
||||
<span class="s-av" style="background:#a855f7">AL</span>
|
||||
<span><span class="s-acct-t">Work</span><span class="s-acct-d">a.lovelace@analytical.co</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-divider"><span>add</span></div>
|
||||
|
||||
<div class="s-stack">
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-plus" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Another account on example.com</span>
|
||||
<span class="s-tile-d">Server already known</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
|
||||
<span class="s-tile-txt"><span class="s-tile-t">A different provider</span></span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Add mode</span>
|
||||
<span class="d-cap-title">Reuse what we already know</span>
|
||||
<p class="d-cap-body">
|
||||
<code>isAddMode</code> currently shows the same blank three-field form. Most second accounts live on
|
||||
the same server as the first, so offer that first and skip discovery entirely.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Notes for the build</h2>
|
||||
<hr class="d-rule" />
|
||||
<ul class="d-notes">
|
||||
<li>
|
||||
<svg><use href="#i-globe" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Discovery is three cheap probes, in order</div>
|
||||
<p class="d-note-b">
|
||||
From <code>ada@example.com</code>: try <code>https://example.com/.well-known/jmap</code>, then
|
||||
<code>https://mail.example.com/.well-known/jmap</code>, then the same host the last account used.
|
||||
First 200 wins. Budget the whole thing at two seconds and fall through to Path B on timeout — a
|
||||
slow lookup must never be worse than the manual form.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-shield" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Nothing new on the wire</div>
|
||||
<p class="d-note-b">
|
||||
Every screen drives an existing store action — <code>loginViaWebmail</code>,
|
||||
<code>loginViaPairing</code>, <code>login</code>. The redesign is routing and copy; the auth
|
||||
surface, the state machine, and <code>parseQrLoginPayload</code> stay as they are.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-alert" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Back must always work</div>
|
||||
<p class="d-note-b">
|
||||
A multi-step flow earns a hardware-back handler on Android. Every step goes back one, and step 1
|
||||
leaves the flow — never a trapped screen with a filled-in field you can't clear.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-key" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Let the OS help</div>
|
||||
<p class="d-note-b">
|
||||
<code>autoComplete="email"</code> and <code>textContentType="username"</code> on the address field,
|
||||
<code>"password"</code> on the fallback field, so the platform password manager offers to fill and
|
||||
save. The current screen sets neither.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="d-foot">Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up</p>
|
||||
</div>
|
||||
Generated
+591
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.62",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.62",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -18,17 +18,23 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-haptics": "^15.0.8",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "^0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@@ -2385,6 +2391,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ide/backoff": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
|
||||
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -3930,6 +3942,19 @@
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
|
||||
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"is-nan": "^1.3.2",
|
||||
"object-is": "^1.1.5",
|
||||
"object.assign": "^4.1.4",
|
||||
"util": "^0.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -3946,6 +3971,21 @@
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -4206,6 +4246,12 @@
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/badgin": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -4411,6 +4457,53 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
@@ -4902,6 +4995,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||
@@ -4911,6 +5021,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5021,6 +5148,20 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -5078,6 +5219,24 @@
|
||||
"stackframe": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@@ -5085,6 +5244,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5212,6 +5383,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
@@ -5232,6 +5412,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-clipboard": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -5246,6 +5437,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
|
||||
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
@@ -5265,6 +5468,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
|
||||
"integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||
@@ -5274,6 +5486,18 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-manipulator": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
|
||||
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||
@@ -5407,6 +5631,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "0.32.17",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
|
||||
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
"@ide/backoff": "^1.0.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"assert": "^2.0.0",
|
||||
"badgin": "^1.1.5",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-secure-store": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
|
||||
@@ -5948,6 +6192,21 @@
|
||||
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/freeport-async": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
|
||||
@@ -5995,6 +6254,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -6013,6 +6281,30 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
@@ -6022,6 +6314,19 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/getenv": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
||||
@@ -6084,6 +6389,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -6099,6 +6416,45 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -6277,12 +6633,40 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -6322,6 +6706,41 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.4",
|
||||
"generator-function": "^2.0.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-nan": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
|
||||
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.0",
|
||||
"define-properties": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -6340,6 +6759,39 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-typed-array": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@@ -7280,6 +7732,15 @@
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -7909,6 +8370,51 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/object.assign": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
||||
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.3",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-object-atoms": "^1.0.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -8209,6 +8715,21 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
|
||||
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
@@ -8954,6 +9475,23 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
@@ -9083,6 +9621,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
@@ -9877,6 +10432,19 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-generator-function": "^1.0.7",
|
||||
"is-typed-array": "^1.1.3",
|
||||
"which-typed-array": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -10208,6 +10776,27 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.22",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||
"integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.9",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.49",
|
||||
"version": "0.1.62",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,9 @@
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"sync-locales": "node scripts/sync-locales.mjs"
|
||||
"sync-locales": "node scripts/sync-locales.mjs",
|
||||
"i18n:harvest": "node scripts/harvest-rn-keys.mjs",
|
||||
"i18n:check": "node scripts/harvest-rn-keys.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -24,17 +26,23 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-haptics": "^15.0.8",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "^0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
|
||||
@@ -14,6 +14,18 @@ const git = (...args) =>
|
||||
const gitInherit = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
|
||||
|
||||
// Pre-flight: the release is created with the GitHub CLI as the last step.
|
||||
// Check it is installed and logged in BEFORE anything is committed, tagged or
|
||||
// pushed - otherwise a missing login leaves a pushed tag with no release (and
|
||||
// no release workflow run), which is exactly what a re-run cannot repair.
|
||||
try {
|
||||
execFileSync('gh', ['auth', 'status'], { cwd: root, stdio: 'ignore' });
|
||||
} catch {
|
||||
console.error('GitHub CLI is not available or not logged in.');
|
||||
console.error('Run `gh auth login` (or set GH_TOKEN), then re-run `npm run bump`.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dirty = git('status', '--porcelain');
|
||||
if (dirty) {
|
||||
console.error('Working tree is not clean. Commit or stash changes before bumping.');
|
||||
@@ -54,10 +66,16 @@ gitInherit('commit', '-m', `chore: update version number to ${next}`);
|
||||
gitInherit('tag', '-a', next, '-m', next);
|
||||
gitInherit('push', '--follow-tags');
|
||||
|
||||
execFileSync(
|
||||
'gh',
|
||||
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
try {
|
||||
execFileSync(
|
||||
'gh',
|
||||
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
} catch {
|
||||
console.error(`The tag ${next} is pushed but the GitHub release could not be created.`);
|
||||
console.error(`Fix the GitHub CLI login and run: gh release create ${next} --title ${next} --generate-notes`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Released ${next}`);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Collect t('key', 'English fallback') calls whose key exists in neither the
|
||||
// vendored webmail catalog nor the RN overlay, and add them to
|
||||
// locales/rn/en.json using the inline fallback as the English text.
|
||||
// Run from the RN repo root: `node scripts/harvest-rn-keys.mjs [--check]`.
|
||||
// `--check` only reports (exit 1 when something is missing) - used by the
|
||||
// translation coverage test message.
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const SRC = join(ROOT, 'src');
|
||||
const EN = join(ROOT, 'locales', 'en', 'common.json');
|
||||
const OVERLAY = join(ROOT, 'locales', 'rn', 'en.json');
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out.set(key, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) {
|
||||
if (entry === '__tests__' || entry === 'node_modules') continue;
|
||||
walk(p, out);
|
||||
} else if (/\.tsx?$/.test(entry)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setDeep(obj, key, value) {
|
||||
const parts = key.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
if (cur[parts[parts.length - 1]] === undefined) cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function sortDeep(obj) {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
||||
return Object.keys(obj).sort().reduce((acc, k) => {
|
||||
acc[k] = sortDeep(obj[k]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const en = flatten(JSON.parse(readFileSync(EN, 'utf8')));
|
||||
const overlay = JSON.parse(readFileSync(OVERLAY, 'utf8'));
|
||||
const overlayFlat = flatten(overlay);
|
||||
|
||||
// t('key', 'fallback') / t('key', "fallback") / t('key', `fallback`) - the
|
||||
// fallback may span lines; keep it simple: single-quoted or double-quoted,
|
||||
// non-nested strings.
|
||||
const CALL_RE = /\bt\(\s*'([^']+)'\s*,\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")/g;
|
||||
const KEY_ONLY_RE = /\bt\(\s*'([^']+)'/g;
|
||||
|
||||
const added = [];
|
||||
const missingNoFallback = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const withFallback = new Map();
|
||||
for (const m of text.matchAll(CALL_RE)) {
|
||||
const value = (m[2] ?? m[3] ?? '').replace(/\\(['"])/g, '$1');
|
||||
if (value) withFallback.set(m[1], value);
|
||||
}
|
||||
for (const m of text.matchAll(KEY_ONLY_RE)) {
|
||||
const key = m[1];
|
||||
if (en.has(key) || overlayFlat.has(key)) continue;
|
||||
const fallback = withFallback.get(key);
|
||||
if (!fallback) {
|
||||
missingNoFallback.push(`${key} (${relative(ROOT, file)})`);
|
||||
continue;
|
||||
}
|
||||
if (!added.some((a) => a.key === key)) added.push({ key, fallback, file: relative(ROOT, file) });
|
||||
}
|
||||
}
|
||||
|
||||
if (added.length === 0 && missingNoFallback.length === 0) {
|
||||
console.log('All t() keys are covered.');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const a of added) console.log(`${CHECK_ONLY ? 'missing' : 'added'}: ${a.key} = ${JSON.stringify(a.fallback)} (${a.file})`);
|
||||
for (const m of missingNoFallback) console.log(`missing (no inline fallback, add by hand): ${m}`);
|
||||
|
||||
if (CHECK_ONLY) process.exit(1);
|
||||
for (const a of added) setDeep(overlay, a.key, a.fallback);
|
||||
writeFileSync(OVERLAY, JSON.stringify(sortDeep(overlay), null, 2) + '\n');
|
||||
console.log(`Wrote ${added.length} key(s) to locales/rn/en.json`);
|
||||
+65
-18
@@ -1,43 +1,90 @@
|
||||
// Copy locale JSON files from the parent webmail repo into ./locales/.
|
||||
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
|
||||
// Vendor the webmail locale catalogs into ./locales/<lang>/common.json.
|
||||
// Run from the RN repo root: `node scripts/sync-locales.mjs [--check]`.
|
||||
//
|
||||
// The webmail catalog is the source of truth and is copied verbatim. Keys the
|
||||
// native app needs that the webmail does not have live in ./locales/rn/<lang>.json
|
||||
// and are merged on top at runtime (src/i18n/index.ts) - they are never written
|
||||
// into the vendored files, so a re-sync cannot lose them. Keys that later land
|
||||
// in the webmail catalog are reported so the overlay can be trimmed.
|
||||
//
|
||||
// In environments where the parent repo isn't checked out (e.g. CI clone of
|
||||
// the standalone RN repo), this is a no-op — vendored files stay as-is.
|
||||
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
|
||||
import { readdir, mkdir, copyFile, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RN_LOCALES = join(__dirname, '..', 'locales');
|
||||
const RN_OVERLAYS = join(RN_LOCALES, 'rn');
|
||||
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
if (!existsSync(WEBMAIL_LOCALES)) {
|
||||
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function copyDir(src, dest) {
|
||||
await mkdir(dest, { recursive: true });
|
||||
for (const entry of await readdir(src, { withFileTypes: true })) {
|
||||
const s = join(src, entry.name);
|
||||
const d = join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
await copyFile(s, d);
|
||||
}
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out.set(key, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name);
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
let copied = 0;
|
||||
let problems = 0;
|
||||
for (const lang of langs) {
|
||||
const src = join(WEBMAIL_LOCALES, lang);
|
||||
const dest = join(RN_LOCALES, lang);
|
||||
await copyDir(src, dest);
|
||||
copied++;
|
||||
const src = join(WEBMAIL_LOCALES, lang, 'common.json');
|
||||
if (!existsSync(src)) continue;
|
||||
const destDir = join(RN_LOCALES, lang);
|
||||
const dest = join(destDir, 'common.json');
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
if (!existsSync(dest)) {
|
||||
console.log(`missing: locales/${lang}/common.json`);
|
||||
problems++;
|
||||
continue;
|
||||
}
|
||||
const [a, b] = await Promise.all([readFile(src, 'utf8'), readFile(dest, 'utf8')]);
|
||||
if (a !== b) {
|
||||
console.log(`stale: locales/${lang}/common.json differs from the webmail`);
|
||||
problems++;
|
||||
}
|
||||
} else {
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(src, dest);
|
||||
copied++;
|
||||
}
|
||||
|
||||
// Report overlay keys the webmail now ships (safe to delete from the overlay)
|
||||
// so the RN-only set stays minimal.
|
||||
const overlayPath = join(RN_OVERLAYS, `${lang}.json`);
|
||||
if (existsSync(overlayPath)) {
|
||||
const webKeys = flatten(await readJson(src));
|
||||
const overlayKeys = flatten(await readJson(overlayPath));
|
||||
const shadowed = [...overlayKeys.keys()].filter((k) => webKeys.has(k));
|
||||
if (shadowed.length > 0) {
|
||||
console.log(
|
||||
`locales/rn/${lang}.json: ${shadowed.length} key(s) now exist in the webmail catalog and shadow it:\n ${shadowed.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
console.log(problems === 0 ? 'Vendored locales are up to date.' : `${problems} locale(s) out of date.`);
|
||||
process.exit(problems === 0 ? 0 : 1);
|
||||
}
|
||||
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
|
||||
|
||||
@@ -7,6 +7,23 @@ vi.mock('expo-file-system', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The streaming upload path is mocked as a controllable task so the tests
|
||||
// can drive progress, completion and cancellation.
|
||||
const uploadTask = {
|
||||
uploadAsync: vi.fn(),
|
||||
cancelAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask);
|
||||
vi.mock('expo-file-system/legacy', () => ({
|
||||
createUploadTask: (...args: unknown[]) => createUploadTask(...args),
|
||||
FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/client-cert', () => ({
|
||||
getClientCertAlias: vi.fn(async () => null),
|
||||
secureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
@@ -18,7 +35,7 @@ vi.mock('../jmap-client', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { getDownloadUrl } from '../blob';
|
||||
import { getDownloadUrl, uploadBlob } from '../blob';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -49,3 +66,63 @@ describe('blob operations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadBlob', () => {
|
||||
it('streams the file through a native upload task with auth headers and reports progress', async () => {
|
||||
let progressCb: ((d: { totalBytesSent: number; totalBytesExpectedToSend: number }) => void) | undefined;
|
||||
createUploadTask.mockImplementationOnce((...args: unknown[]) => {
|
||||
progressCb = args[3] as typeof progressCb;
|
||||
return uploadTask;
|
||||
});
|
||||
uploadTask.uploadAsync.mockImplementationOnce(async () => {
|
||||
progressCb?.({ totalBytesSent: 50, totalBytesExpectedToSend: 100 });
|
||||
return { status: 201, body: JSON.stringify({ blobId: 'blob-1', size: 100, type: 'image/png' }) };
|
||||
});
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const result = await uploadBlob('file:///tmp/a.png', 'image/png', { onProgress });
|
||||
|
||||
expect(result).toEqual({ blobId: 'blob-1', size: 100, type: 'image/png' });
|
||||
expect(onProgress).toHaveBeenCalledWith(50, 100);
|
||||
const [url, uri, options] = createUploadTask.mock.calls[0] as unknown as [string, string, { headers: Record<string, string>; httpMethod: string }];
|
||||
expect(url).toBe('https://mail.example.com/upload/acc-1/');
|
||||
expect(uri).toBe('file:///tmp/a.png');
|
||||
expect(options.httpMethod).toBe('POST');
|
||||
expect(options.headers).toEqual({ 'Content-Type': 'image/png', Authorization: 'Basic dXNlcjpwYXNz' });
|
||||
});
|
||||
|
||||
it('accepts the per-account nested response shape', async () => {
|
||||
uploadTask.uploadAsync.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: JSON.stringify({ 'acc-1': { blobId: 'blob-2', size: 7, type: 'text/plain' } }),
|
||||
});
|
||||
const result = await uploadBlob('file:///tmp/a.txt', 'text/plain');
|
||||
expect(result.blobId).toBe('blob-2');
|
||||
});
|
||||
|
||||
it('surfaces HTTP failures with the status code', async () => {
|
||||
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 413, body: 'too large' });
|
||||
await expect(uploadBlob('file:///tmp/a.bin', 'application/octet-stream')).rejects.toThrow(/413/);
|
||||
});
|
||||
|
||||
it('cancels the native task when the signal aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
uploadTask.uploadAsync.mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return null;
|
||||
});
|
||||
await expect(
|
||||
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(uploadTask.cancelAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(createUploadTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,21 @@ import {
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvents,
|
||||
toLocalDateTime,
|
||||
} from '../calendar';
|
||||
|
||||
describe('toLocalDateTime', () => {
|
||||
it('renders an instant as wall-clock in the given zone', () => {
|
||||
expect(toLocalDateTime('2026-03-01T00:30:00Z', 'Europe/Berlin')).toBe('2026-03-01T01:30:00');
|
||||
expect(toLocalDateTime('2026-03-01T00:30:00Z', 'America/New_York')).toBe('2026-02-28T19:30:00');
|
||||
expect(toLocalDateTime('2026-07-01T23:15:00.000Z', 'UTC')).toBe('2026-07-01T23:15:00');
|
||||
});
|
||||
|
||||
it('passes LocalDateTime strings through', () => {
|
||||
expect(toLocalDateTime('2026-03-01T00:00:00', 'UTC')).toMatch(/^2026-0[23]-\d{2}T\d{2}:00:00$/);
|
||||
});
|
||||
});
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -43,20 +56,71 @@ describe('calendar operations', () => {
|
||||
|
||||
describe('queryEvents', () => {
|
||||
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter (and
|
||||
// after/before); calendars are restricted via singular `inCalendar`
|
||||
// conditions and date filtering stays client-side.
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter; calendars
|
||||
// are restricted via singular `inCalendar` conditions.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
|
||||
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
|
||||
const result = await queryEvents(['cal-1'], '', '');
|
||||
expect(result).toEqual(['ev1', 'ev2']);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[0]).toBe('CalendarEvent/query');
|
||||
// No bounds: only the calendar restriction is sent.
|
||||
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
expect(call[1].position).toBe(0);
|
||||
});
|
||||
|
||||
it('should send the date window as LocalDateTime after/before combined with the calendar filter', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter.operator).toBe('AND');
|
||||
expect(call[1].filter.conditions[0]).toEqual({ inCalendar: 'cal-1' });
|
||||
const range = call[1].filter.conditions[1];
|
||||
expect(range.after).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
expect(range.before).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
expect(call[1].timeZone).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should send only the range when no calendars are given', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents([], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter.operator).toBeUndefined();
|
||||
expect(Object.keys(call[1].filter).sort()).toEqual(['after', 'before']);
|
||||
});
|
||||
|
||||
it('should page through a result set larger than one page', async () => {
|
||||
// Regression: a full page used to be returned as the complete answer, so
|
||||
// an account with more events than the page size silently lost every
|
||||
// event past it - the calendar rendered empty.
|
||||
const first = Array.from({ length: 1000 }, (_, i) => `a${i}`);
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/query', { ids: first }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']],
|
||||
});
|
||||
|
||||
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
|
||||
|
||||
expect(result).toHaveLength(1002);
|
||||
expect(result[1001]).toBe('tail-2');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0);
|
||||
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000);
|
||||
});
|
||||
|
||||
it('should OR multiple calendars and respect the target account', async () => {
|
||||
@@ -74,7 +138,7 @@ describe('calendar operations', () => {
|
||||
expect(call[1].accountId).toBe('acc-shared');
|
||||
});
|
||||
|
||||
it('should omit the filter when no calendars are given', async () => {
|
||||
it('should omit the filter when no calendars are given and window is empty', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
@@ -87,7 +151,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('getEvents', () => {
|
||||
it('should fetch events by id with expected properties', async () => {
|
||||
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
|
||||
// Stalwart (calcard) names the property `recurrenceRule` (singular,
|
||||
// single object) — requesting the RFC 8984 plural form returns no
|
||||
// recurrence data and repeating events vanish from the grid (#13).
|
||||
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
@@ -99,7 +166,48 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].properties).toContain('title');
|
||||
expect(call[1].properties).toContain('start');
|
||||
expect(call[1].properties).toContain('recurrenceRules');
|
||||
expect(call[1].properties).toContain('recurrenceRule');
|
||||
expect(call[1].properties).toContain('excludedRecurrenceRule');
|
||||
expect(call[1].properties).not.toContain('recurrenceRules');
|
||||
expect(call[1].properties).not.toContain('excludedRecurrenceRules');
|
||||
});
|
||||
|
||||
it('should normalize a singular recurrenceRule object to the plural array form', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Every 5 weeks',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([
|
||||
{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
]);
|
||||
expect(event.excludedRecurrenceRules).toEqual([
|
||||
{ frequency: 'weekly', interval: 10 },
|
||||
]);
|
||||
expect(event).not.toHaveProperty('recurrenceRule');
|
||||
expect(event).not.toHaveProperty('excludedRecurrenceRule');
|
||||
});
|
||||
|
||||
it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Weekly',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,10 +223,39 @@ describe('calendar operations', () => {
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
// The /set response echoes only server-set properties; the return value
|
||||
// merges them over the submitted payload.
|
||||
expect(result).toEqual({
|
||||
id: 'ev-new',
|
||||
title: 'Lunch',
|
||||
start: '2026-03-30T12:00:00',
|
||||
duration: 'PT1H',
|
||||
calendarIds: { 'cal-1': true },
|
||||
});
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
|
||||
});
|
||||
|
||||
it('should send recurrence as a singular cleaned object', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']],
|
||||
});
|
||||
|
||||
const result = await createEvent(
|
||||
{
|
||||
title: 'Series',
|
||||
start: '2026-03-30T12:00:00',
|
||||
recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }],
|
||||
},
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
const sent = mockRequest.mock.calls[0][0][0][1].create['new-event'];
|
||||
expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 });
|
||||
expect(sent).not.toHaveProperty('recurrenceRules');
|
||||
// The returned event exposes the internal plural form again.
|
||||
expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
@@ -132,6 +269,23 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
|
||||
});
|
||||
|
||||
it('should convert recurrenceRules updates to the singular property', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
|
||||
});
|
||||
|
||||
await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] });
|
||||
expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: { frequency: 'monthly' } },
|
||||
});
|
||||
|
||||
// Empty array means "remove recurrence" — sent as an explicit null.
|
||||
await updateEvent('ev1', { recurrenceRules: [] });
|
||||
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvents', () => {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const session = {
|
||||
primaryAccounts: {} as Record<string, string>,
|
||||
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
|
||||
};
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
username: 'me@example.com',
|
||||
get currentSession() { return session; },
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
@@ -11,13 +18,18 @@ vi.mock('../jmap-client', () => ({
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import {
|
||||
getAddressBooks,
|
||||
getAllAddressBooks,
|
||||
getContactsAccountId,
|
||||
getContactCapableAccountIds,
|
||||
queryContacts,
|
||||
getContacts,
|
||||
getAllContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContacts,
|
||||
createAddressBook,
|
||||
updateAddressBook,
|
||||
setDefaultAddressBook,
|
||||
deleteAddressBook,
|
||||
} from '../contacts';
|
||||
|
||||
@@ -25,11 +37,31 @@ const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
session.primaryAccounts = {};
|
||||
session.accounts = {};
|
||||
});
|
||||
|
||||
describe('contacts operations', () => {
|
||||
describe('account resolution', () => {
|
||||
it('prefers the contacts primary account over the mail account', () => {
|
||||
expect(getContactsAccountId()).toBe('acc-1');
|
||||
session.primaryAccounts = { 'urn:ietf:params:jmap:contacts': 'acc-contacts' };
|
||||
expect(getContactsAccountId()).toBe('acc-contacts');
|
||||
});
|
||||
|
||||
it('lists contacts-capable and non-personal accounts after the primary', () => {
|
||||
session.accounts = {
|
||||
'acc-1': { name: 'me', isPersonal: true },
|
||||
'acc-team': { name: 'Team', isPersonal: false },
|
||||
'acc-cal': { name: 'Cal', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
|
||||
'acc-ct': { name: 'Ct', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:contacts': {} } },
|
||||
};
|
||||
expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team', 'acc-ct']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAddressBooks', () => {
|
||||
it('should fetch address books', async () => {
|
||||
it('should fetch address books with an explicit property list', async () => {
|
||||
const books = [{ id: 'ab-1', name: 'Personal' }];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/get', { list: books }, '0']],
|
||||
@@ -37,21 +69,64 @@ describe('contacts operations', () => {
|
||||
|
||||
const result = await getAddressBooks();
|
||||
expect(result).toEqual(books);
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
[['AddressBook/get', { accountId: 'acc-1' }, '0']],
|
||||
expect.arrayContaining(['urn:ietf:params:jmap:contacts']),
|
||||
);
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[0]).toBe('AddressBook/get');
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
expect(call[1].properties).toEqual(expect.arrayContaining(['shareWith', 'myRights', 'isDefault']));
|
||||
expect(mockRequest.mock.calls[0][1]).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:contacts']));
|
||||
});
|
||||
|
||||
it('throws on a method-level error', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['error', { type: 'serverFail', description: 'boom' }, '0']],
|
||||
});
|
||||
await expect(getAddressBooks()).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllAddressBooks', () => {
|
||||
it('namespaces books from shared accounts and tags them', async () => {
|
||||
session.accounts = {
|
||||
'acc-1': { name: 'me', isPersonal: true },
|
||||
'acc-team': { name: 'Team', isPersonal: false },
|
||||
};
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Shared' }] }, '0']] });
|
||||
|
||||
const books = await getAllAddressBooks();
|
||||
expect(books).toHaveLength(2);
|
||||
expect(books[0]).toMatchObject({ id: 'default', originalId: 'default', isShared: false });
|
||||
expect(books[1]).toMatchObject({
|
||||
id: 'acc-team:default', originalId: 'default', accountId: 'acc-team', accountName: 'Team', isShared: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps own books when a shared account fails', async () => {
|
||||
session.accounts = {
|
||||
'acc-1': { name: 'me', isPersonal: true },
|
||||
'acc-team': { name: 'Team', isPersonal: false },
|
||||
};
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
|
||||
.mockRejectedValueOnce(new Error('forbidden'));
|
||||
|
||||
const books = await getAllAddressBooks();
|
||||
expect(books.map((b) => b.id)).toEqual(['default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryContacts', () => {
|
||||
it('should query contacts with text filter', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']],
|
||||
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'], total: 2 }, '0']],
|
||||
});
|
||||
|
||||
const result = await queryContacts({ text: 'john' });
|
||||
expect(result).toEqual(['c1', 'c2']);
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ text: 'john' });
|
||||
expect(call[1].position).toBe(0);
|
||||
});
|
||||
|
||||
it('should omit filter when none provided', async () => {
|
||||
@@ -63,6 +138,28 @@ describe('contacts operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('walks position until every id is collected', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['a', 'b'], total: 5 }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c', 'd'], total: 5 }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['e'], total: 5 }, '0']] });
|
||||
|
||||
const result = await queryContacts(undefined, 2);
|
||||
expect(result).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(3);
|
||||
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(2);
|
||||
expect(mockRequest.mock.calls[2][0][0][1].position).toBe(4);
|
||||
});
|
||||
|
||||
it('stops when the server returns a short page without a total', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/query', { ids: ['a'] }, '0']],
|
||||
});
|
||||
const result = await queryContacts(undefined, 2);
|
||||
expect(result).toEqual(['a']);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContacts', () => {
|
||||
@@ -77,21 +174,85 @@ describe('contacts operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContact', () => {
|
||||
it('should create a contact in the specified address book', async () => {
|
||||
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']],
|
||||
describe('getAllContacts', () => {
|
||||
it('namespaces cards and their book ids for shared accounts', async () => {
|
||||
session.accounts = {
|
||||
'acc-1': { name: 'me', isPersonal: true },
|
||||
'acc-team': { name: 'Team', isPersonal: false },
|
||||
};
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c1'], total: 1 }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { default: true } }] }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c9'], total: 1 }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c9', addressBookIds: { default: true } }] }, '0']] });
|
||||
|
||||
const cards = await getAllContacts();
|
||||
expect(cards[0]).toEqual({ id: 'c1', addressBookIds: { default: true } });
|
||||
expect(cards[1]).toMatchObject({
|
||||
id: 'acc-team:c9',
|
||||
originalId: 'c9',
|
||||
addressBookIds: { 'acc-team:default': true },
|
||||
accountId: 'acc-team',
|
||||
isShared: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContact', () => {
|
||||
it('assigns a urn:uuid UID, then re-fetches the created card', async () => {
|
||||
const full = {
|
||||
id: 'c-new',
|
||||
uid: 'urn:uuid:whatever',
|
||||
addressBookIds: { 'ab-1': true },
|
||||
name: { components: [{ kind: 'given', value: 'Jane' }] },
|
||||
};
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['ContactCard/get', { list: [full] }, '0']],
|
||||
});
|
||||
|
||||
const result = await createContact(
|
||||
{ name: { components: [{ kind: 'given', value: 'Jane' }] } },
|
||||
'ab-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true });
|
||||
expect(result).toEqual(full);
|
||||
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
|
||||
expect(create.addressBookIds).toEqual({ 'ab-1': true });
|
||||
expect(create.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/);
|
||||
expect(mockRequest.mock.calls[1][0][0][0]).toBe('ContactCard/get');
|
||||
expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['c-new']);
|
||||
});
|
||||
|
||||
it('keeps an existing uid and strips client-only fields', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
|
||||
|
||||
const result = await createContact(
|
||||
{ uid: 'urn:uuid:keep', isShared: true, accountName: 'x', name: { full: 'A' } },
|
||||
'ab-1',
|
||||
);
|
||||
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
|
||||
expect(create.uid).toBe('urn:uuid:keep');
|
||||
expect(create.isShared).toBeUndefined();
|
||||
expect(create.accountName).toBeUndefined();
|
||||
// Falls back to a client-side merge when the re-fetch comes back empty.
|
||||
expect(result).toMatchObject({ id: 'c-new', uid: 'urn:uuid:keep', name: { full: 'A' }, addressBookIds: { 'ab-1': true } });
|
||||
});
|
||||
|
||||
it('throws the server description when the card is rejected', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'bad date', properties: ['anniversaries'] } },
|
||||
}, '0']],
|
||||
});
|
||||
await expect(createContact({ name: { full: 'A' } }, 'ab-1')).rejects.toThrow('bad date (anniversaries)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,18 +267,46 @@ describe('contacts operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
|
||||
});
|
||||
|
||||
it('throws when the server refuses the update', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', { notUpdated: { c1: { type: 'forbidden', description: 'read-only book' } } }, '0']],
|
||||
});
|
||||
await expect(updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only book');
|
||||
});
|
||||
|
||||
it('routes to the given account', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: {} }, '0']] });
|
||||
await updateContact('c1', { kind: 'org' }, 'acc-team');
|
||||
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteContacts', () => {
|
||||
it('should destroy contacts by ids', async () => {
|
||||
it('should destroy contacts by ids and return the destroyed ones', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
|
||||
});
|
||||
|
||||
await deleteContacts(['c1', 'c2']);
|
||||
const destroyed = await deleteContacts(['c1', 'c2']);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].destroy).toEqual(['c1', 'c2']);
|
||||
expect(destroyed).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('returns only the destroyed subset on partial failure', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', { destroyed: ['c1'], notDestroyed: { c2: { type: 'forbidden' } } }, '0']],
|
||||
});
|
||||
expect(await deleteContacts(['c1', 'c2'])).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('throws when nothing was destroyed', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/set', { notDestroyed: { c1: { type: 'forbidden', description: 'nope' } } }, '0']],
|
||||
});
|
||||
await expect(deleteContacts(['c1'])).rejects.toThrow('nope');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,18 +332,36 @@ describe('contacts operations', () => {
|
||||
});
|
||||
|
||||
describe('updateAddressBook', () => {
|
||||
it('forwards only settable properties', async () => {
|
||||
it('forwards only settable properties and never isDefault', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
|
||||
await updateAddressBook('ab-1', { name: 'Renamed', isDefault: true, id: 'should-be-ignored' } as never);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDefaultAddressBook', () => {
|
||||
it('uses onSuccessSetIsDefault', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { updated: {} }, '0']],
|
||||
});
|
||||
await setDefaultAddressBook('ab-2');
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1]).toEqual({ accountId: 'acc-1', onSuccessSetIsDefault: 'ab-2' });
|
||||
});
|
||||
|
||||
it('throws on a method error', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['error', { type: 'invalidArguments', description: 'no such book' }, '0']],
|
||||
});
|
||||
await expect(setDefaultAddressBook('ab-x')).rejects.toThrow('no such book');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAddressBook', () => {
|
||||
it('destroys the book by id', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
|
||||
@@ -5,12 +5,18 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getAccountName: vi.fn(() => 'me@example.com'),
|
||||
getSharedMailAccounts: vi.fn(() => []),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import {
|
||||
getMailboxes,
|
||||
getSharedMailboxes,
|
||||
queryEmails,
|
||||
getEmails,
|
||||
getFullEmail,
|
||||
@@ -41,13 +47,107 @@ describe('email operations', () => {
|
||||
|
||||
const result = await getMailboxes();
|
||||
|
||||
expect(result).toEqual(mailboxes);
|
||||
// Own folders keep their raw ids and pick up ownership metadata.
|
||||
expect(result).toEqual(mailboxes.map((m) => ({
|
||||
...m,
|
||||
accountId: 'acc-1',
|
||||
accountName: 'me@example.com',
|
||||
isShared: false,
|
||||
})));
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedMailboxes', () => {
|
||||
const mockShared = jmapClient.getSharedMailAccounts as ReturnType<typeof vi.fn>;
|
||||
|
||||
it('returns nothing when the session has no shared accounts', async () => {
|
||||
mockShared.mockReturnValue([]);
|
||||
|
||||
expect(await getSharedMailboxes()).toEqual([]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefixes ids and parent links per owning account', async () => {
|
||||
mockShared.mockReturnValue([{ id: 'grp-1', name: 'Support' }]);
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Mailbox/get', {
|
||||
list: [
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 4, unreadEmails: 2 },
|
||||
{ id: 'mb-2', name: 'Escalations', parentId: 'mb-1', totalEmails: 1, unreadEmails: 0 },
|
||||
],
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
const result = await getSharedMailboxes();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
[['Mailbox/get', { accountId: 'grp-1' }, '0']],
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'grp-1:mb-1',
|
||||
originalId: 'mb-1',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
parentId: undefined,
|
||||
totalEmails: 4,
|
||||
unreadEmails: 2,
|
||||
accountId: 'grp-1',
|
||||
accountName: 'Support',
|
||||
isShared: true,
|
||||
},
|
||||
{
|
||||
id: 'grp-1:mb-2',
|
||||
originalId: 'mb-2',
|
||||
name: 'Escalations',
|
||||
parentId: 'grp-1:mb-1',
|
||||
totalEmails: 1,
|
||||
unreadEmails: 0,
|
||||
accountId: 'grp-1',
|
||||
accountName: 'Support',
|
||||
isShared: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps each response back to its account and skips the ones that errored', async () => {
|
||||
mockShared.mockReturnValue([
|
||||
{ id: 'grp-1', name: 'Support' },
|
||||
{ id: 'grp-2', name: 'Sales' },
|
||||
]);
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['error', { type: 'accountNotFound' }, '0'],
|
||||
['Mailbox/get', { list: [{ id: 'mb-9', name: 'Inbox', role: 'inbox' }] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSharedMailboxes();
|
||||
|
||||
expect(result.map((m) => m.id)).toEqual(['grp-2:mb-9']);
|
||||
expect(result[0].accountName).toBe('Sales');
|
||||
});
|
||||
|
||||
it('chunks requests to maxCallsInRequest', async () => {
|
||||
mockShared.mockReturnValue([
|
||||
{ id: 'grp-1', name: 'Support' },
|
||||
{ id: 'grp-2', name: 'Sales' },
|
||||
{ id: 'grp-3', name: 'Billing' },
|
||||
]);
|
||||
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
mockRequest.mockResolvedValue({ methodResponses: [] });
|
||||
|
||||
await getSharedMailboxes();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequest.mock.calls[0][0]).toHaveLength(2);
|
||||
expect(mockRequest.mock.calls[1][0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEmails', () => {
|
||||
it('should query emails with default options', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
@@ -296,16 +396,18 @@ describe('email operations', () => {
|
||||
to: [{ email: 'you@example.com' }],
|
||||
subject: 'Re: Hello',
|
||||
textBody: 'replying',
|
||||
inReplyTo: '<msg-1@example.com>',
|
||||
references: '<msg-0@example.com> <msg-1@example.com>',
|
||||
inReplyTo: ['<msg-1@example.com>'],
|
||||
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||
},
|
||||
'identity-1',
|
||||
'sent-mb',
|
||||
);
|
||||
|
||||
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
|
||||
expect(emailCreate['header:In-Reply-To:asText']).toBe('<msg-1@example.com>');
|
||||
expect(emailCreate['header:References:asText']).toBe('<msg-0@example.com> <msg-1@example.com>');
|
||||
// RFC 8621 §4.1.2.3: JMAP arrays of bare msg-ids, brackets stripped.
|
||||
expect(emailCreate.inReplyTo).toEqual(['msg-1@example.com']);
|
||||
expect(emailCreate.references).toEqual(['msg-0@example.com', 'msg-1@example.com']);
|
||||
expect(emailCreate['header:In-Reply-To:asText']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ vi.mock('../jmap-client', () => ({
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
hasCapability: vi.fn(),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getMaxSizeUpload: vi.fn(() => 0),
|
||||
currentSession: null as unknown,
|
||||
},
|
||||
}));
|
||||
@@ -21,14 +23,19 @@ vi.mock('../blob', () => ({
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import {
|
||||
accountSupportsFiles,
|
||||
copyFileNode,
|
||||
createFolder,
|
||||
deleteFileNodes,
|
||||
getAllFileNodes,
|
||||
getAllFileNodesAcrossAccounts,
|
||||
getFileNodeDownloadUrl,
|
||||
getPrincipals,
|
||||
isCrossAccountId,
|
||||
isFolder,
|
||||
moveFileNode,
|
||||
setFileNodeShare,
|
||||
supportsSharing,
|
||||
} from '../files';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -41,7 +48,10 @@ function setSession(session: unknown) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasCapability.mockImplementation(
|
||||
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
|
||||
(urn: string) =>
|
||||
urn === CAPABILITIES.FILES ||
|
||||
urn === CAPABILITIES.PRINCIPALS ||
|
||||
urn === CAPABILITIES.PRINCIPALS_OWNER,
|
||||
);
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
@@ -250,3 +260,100 @@ describe('getPrincipals', () => {
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFileNodeName at the API boundary (#869)', () => {
|
||||
it('decodes percent-encoded names from getAllFileNodes and the cross-account list', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/get', {
|
||||
list: [
|
||||
{ id: 'n1', name: 'Spares%20Catalog', blobId: null },
|
||||
{ id: 'n2', name: '100% done.txt', blobId: 'b2' },
|
||||
],
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
const own = await getAllFileNodes();
|
||||
expect(own.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
|
||||
|
||||
const all = await getAllFileNodesAcrossAccounts();
|
||||
expect(all.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
|
||||
});
|
||||
|
||||
it('requests the real "modified" property, not "updated" (#700)', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/get', { list: [] }, '0']] });
|
||||
await getAllFileNodes();
|
||||
const [, args] = mockRequest.mock.calls[0][0][0];
|
||||
expect(args.properties).toContain('modified');
|
||||
expect(args.properties).not.toContain('updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyFileNode', () => {
|
||||
it('creates a new node that reuses the blob instead of re-uploading', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'copy-1' } } }, '0']],
|
||||
});
|
||||
|
||||
const copy = await copyFileNode(
|
||||
{ id: 'f1', name: 'report.pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1' },
|
||||
'dir-1',
|
||||
'report (1).pdf',
|
||||
);
|
||||
|
||||
const [method, args] = mockRequest.mock.calls[0][0][0];
|
||||
expect(method).toBe('FileNode/set');
|
||||
expect(args.create['new-file']).toEqual({
|
||||
name: 'report (1).pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1',
|
||||
});
|
||||
expect(copy.id).toBe('copy-1');
|
||||
});
|
||||
|
||||
it('refuses to duplicate folders', async () => {
|
||||
await expect(copyFileNode({ id: 'd', name: 'Docs', type: '', blobId: null }, null)).rejects.toThrow();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveFileNode', () => {
|
||||
it('sends an explicit null parentId when moving to the root', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { updated: { f1: null } }, '0']] });
|
||||
await moveFileNode('f1', null);
|
||||
const [, args] = mockRequest.mock.calls[0][0][0];
|
||||
expect(args.update.f1).toEqual({ parentId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountSupportsFiles (#563)', () => {
|
||||
const caps = { [CAPABILITIES.FILES]: {} };
|
||||
it('requires the account capability on personal accounts', () => {
|
||||
expect(accountSupportsFiles({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(false);
|
||||
expect(accountSupportsFiles(
|
||||
{ name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.FILES]: {} } },
|
||||
caps,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats non-personal accounts as capable and needs the session capability', () => {
|
||||
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true);
|
||||
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false);
|
||||
expect(accountSupportsFiles(undefined, caps)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsSharing', () => {
|
||||
it('accepts principals:owner from the session or the account capabilities', () => {
|
||||
const mockHasAccountCapability = jmapClient.hasAccountCapability as ReturnType<typeof vi.fn>;
|
||||
mockHasCapability.mockImplementation((urn: string) => urn === CAPABILITIES.FILES);
|
||||
mockHasAccountCapability.mockReturnValue(false);
|
||||
expect(supportsSharing()).toBe(false);
|
||||
|
||||
mockHasAccountCapability.mockImplementation((urn: string) => urn === CAPABILITIES.PRINCIPALS_OWNER);
|
||||
expect(supportsSharing()).toBe(true);
|
||||
|
||||
mockHasAccountCapability.mockReturnValue(false);
|
||||
mockHasCapability.mockImplementation(
|
||||
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS_OWNER,
|
||||
);
|
||||
expect(supportsSharing()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,15 @@ describe('identity operations', () => {
|
||||
describe('createIdentity', () => {
|
||||
it('should create a new identity', async () => {
|
||||
const created = { id: 'id-new', name: 'Work', email: 'work@example.com' };
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': created } }, '0']],
|
||||
});
|
||||
// The create echo carries only server-set properties; the API re-reads
|
||||
// the full identity afterwards.
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'id-new' } } }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['Identity/get', { list: [created] }, '0']],
|
||||
});
|
||||
|
||||
const result = await createIdentity({ name: 'Work', email: 'work@example.com' });
|
||||
expect(result).toEqual(created);
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
setItemAsync: vi.fn(),
|
||||
getItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockRefresh } = vi.hoisted(() => ({ mockRefresh: vi.fn() }));
|
||||
vi.mock('../../lib/oauth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../lib/oauth')>();
|
||||
return { ...actual, refreshOAuthAccessToken: mockRefresh };
|
||||
});
|
||||
|
||||
import {
|
||||
JMAPClient,
|
||||
AuthenticationError,
|
||||
NetworkError,
|
||||
RateLimitError,
|
||||
RequestTimeoutError,
|
||||
TotpRequiredError,
|
||||
JMAPMethodError,
|
||||
requireMethodResult,
|
||||
assertSetResult,
|
||||
rewriteSessionUrl,
|
||||
parseRetryAfter,
|
||||
isConcurrentRequestRefusal,
|
||||
batched,
|
||||
} from '../jmap-client';
|
||||
import { TransientRefreshError } from '../../lib/oauth';
|
||||
import { FirstTouchGate, gateKeysFor } from '../first-touch-gate';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
const SESSION: JMAPSession = {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': {
|
||||
name: 'user@example.com',
|
||||
isPersonal: true,
|
||||
isReadOnly: false,
|
||||
accountCapabilities: { 'urn:stalwart:jmap': {} },
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': { maxObjectsInSet: 50, maxSizeUpload: 1024 },
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
},
|
||||
state: 's1',
|
||||
username: 'user@example.com',
|
||||
};
|
||||
|
||||
type Resp = {
|
||||
status: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
headers?: Record<string, string>;
|
||||
redirected?: boolean;
|
||||
url?: string;
|
||||
delayMs?: number;
|
||||
throw?: Error;
|
||||
};
|
||||
|
||||
function makeResponse(resp: Resp) {
|
||||
const text = resp.text ?? (resp.json === undefined ? '' : JSON.stringify(resp.json));
|
||||
return {
|
||||
ok: resp.status >= 200 && resp.status < 300,
|
||||
status: resp.status,
|
||||
statusText: 'x',
|
||||
redirected: resp.redirected ?? false,
|
||||
url: resp.url ?? '',
|
||||
headers: { get: (n: string) => resp.headers?.[n] ?? null },
|
||||
json: async () => (resp.json !== undefined ? resp.json : JSON.parse(text)),
|
||||
text: async () => text,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(responses: Resp[]) {
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const resp = responses[i++] ?? responses[responses.length - 1];
|
||||
if (resp.throw) throw resp.throw;
|
||||
if (resp.delayMs) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(resolve, resp.delayMs);
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t);
|
||||
const e = new Error('Aborted');
|
||||
e.name = 'AbortError';
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
return makeResponse(resp);
|
||||
});
|
||||
global.fetch = fn as unknown as typeof fetch;
|
||||
return fn;
|
||||
}
|
||||
|
||||
async function connected(): Promise<JMAPClient> {
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
const client = new JMAPClient();
|
||||
await client.connect('https://mail.example.com', 'user', 'pass');
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('pure helpers', () => {
|
||||
it('rewriteSessionUrl rebases relative and foreign-origin URLs, keeps templates', () => {
|
||||
const origin = 'https://mail.example.com';
|
||||
expect(rewriteSessionUrl('/jmap/', origin)).toBe('https://mail.example.com/jmap/');
|
||||
expect(rewriteSessionUrl('jmap/', origin)).toBe('https://mail.example.com/jmap/');
|
||||
expect(rewriteSessionUrl('http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}', origin))
|
||||
.toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
expect(rewriteSessionUrl('https://mail.example.com/x', origin)).toBe('https://mail.example.com/x');
|
||||
expect(rewriteSessionUrl(undefined, origin)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseRetryAfter handles seconds, HTTP-date, garbage', () => {
|
||||
expect(parseRetryAfter('10')).toBe(10_000);
|
||||
expect(parseRetryAfter('9999')).toBe(300_000);
|
||||
const future = new Date(Date.now() + 30_000).toUTCString();
|
||||
const ms = parseRetryAfter(future);
|
||||
expect(ms).toBeGreaterThan(25_000);
|
||||
expect(ms).toBeLessThanOrEqual(31_000);
|
||||
expect(parseRetryAfter('bogus')).toBe(60_000);
|
||||
expect(parseRetryAfter(null)).toBe(60_000);
|
||||
});
|
||||
|
||||
it('isConcurrentRequestRefusal matches only the limit error', () => {
|
||||
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' }))).toBe(true);
|
||||
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxSizeRequest' }))).toBe(false);
|
||||
expect(isConcurrentRequestRefusal(500, 'nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('requireMethodResult throws JMAPMethodError on the error envelope', () => {
|
||||
const res = { methodResponses: [['error', { type: 'forbidden', description: 'no' }, '0']] as Array<[string, Record<string, any>, string]> };
|
||||
expect(() => requireMethodResult(res, '0')).toThrow(JMAPMethodError);
|
||||
expect(() => requireMethodResult(res)).toThrow('no');
|
||||
const ok = { methodResponses: [['Mailbox/get', { list: [1] }, 'a']] as Array<[string, Record<string, any>, string]> };
|
||||
expect(requireMethodResult(ok, 'a', 'Mailbox/get')).toEqual({ list: [1] });
|
||||
expect(() => requireMethodResult(ok, 'a', 'Email/get')).toThrow('Expected Email/get');
|
||||
expect(() => requireMethodResult(ok, 'zz')).toThrow('Missing JMAP response');
|
||||
});
|
||||
|
||||
it('assertSetResult throws for notUpdated / notDestroyed / notCreated', () => {
|
||||
expect(() => assertSetResult({ updated: { a: null } }, ['a'])).not.toThrow();
|
||||
expect(() => assertSetResult({ notUpdated: { a: { type: 'forbidden' } } }, ['a'])).toThrow('update');
|
||||
expect(() => assertSetResult({ notUpdated: { b: { type: 'forbidden' } } }, ['a'])).not.toThrow();
|
||||
expect(() => assertSetResult({ notDestroyed: { a: { type: 'notFound', description: 'gone' } } })).toThrow('gone');
|
||||
expect(() => assertSetResult({ notCreated: { x: { type: 'invalidProperties', properties: ['uid'] } } }, undefined, 'contact')).toThrow('properties=[uid]');
|
||||
});
|
||||
|
||||
it('batched splits lists', () => {
|
||||
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
expect(batched([], 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FirstTouchGate', () => {
|
||||
it('serialises the first calendar/contacts request per account', async () => {
|
||||
const gate = new FirstTouchGate();
|
||||
const order: string[] = [];
|
||||
let release!: () => void;
|
||||
const first = gate.run([['Calendar/get', { accountId: 'a' }, '0']], () => new Promise<string>((resolve) => {
|
||||
release = () => { order.push('first'); resolve('first'); };
|
||||
}));
|
||||
const second = gate.run([['CalendarEvent/query', { accountId: 'a' }, '0']], async () => { order.push('second'); return 'second'; });
|
||||
const other = gate.run([['Mailbox/get', { accountId: 'a' }, '0']], async () => { order.push('mail'); return 'mail'; });
|
||||
await other;
|
||||
expect(order).toEqual(['mail']);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(['mail', 'first', 'second']);
|
||||
expect(gateKeysFor([['AddressBook/get', { accountId: 'b' }, '0'], ['Email/get', { accountId: 'b' }, '1']])).toEqual(['contacts:b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAPClient hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockRefresh.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves relative session URLs against the login origin', async () => {
|
||||
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: '/jmap/', downloadUrl: '/download/{accountId}/{blobId}/{name}?type={type}' } }]);
|
||||
const client = new JMAPClient();
|
||||
const session = await client.connect('https://mail.example.com', 'user', 'pass');
|
||||
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||
expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream');
|
||||
});
|
||||
|
||||
it('refetches the final URL when a redirect dropped the Authorization header', async () => {
|
||||
const fetch = mockFetch([
|
||||
{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, redirected: true, url: 'https://mail.example.com/jmap/session' },
|
||||
{ status: 200, json: SESSION },
|
||||
]);
|
||||
const client = new JMAPClient();
|
||||
await client.connect('https://mail.example.com', 'user', 'pass');
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session');
|
||||
expect(client.accountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('throws TotpRequiredError on a 402 MFA challenge', async () => {
|
||||
mockFetch([{ status: 402, json: { title: 'MFA code required' } }]);
|
||||
const client = new JMAPClient();
|
||||
const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(TotpRequiredError);
|
||||
expect(err).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it('times out a stalled request with RequestTimeoutError and does not retry it', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([{ status: 200, json: { methodResponses: [] }, delayMs: 60_000 }]);
|
||||
const p = client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]);
|
||||
const settled = p.catch((e) => e);
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
const err = await settled;
|
||||
expect(err).toBeInstanceOf(RequestTimeoutError);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an idempotent request once after a transient network error', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([
|
||||
{ status: 200, throw: new TypeError('Network request failed') },
|
||||
{ status: 200, json: { methodResponses: [['Mailbox/get', { list: [] }, '0']] } },
|
||||
]);
|
||||
const p = client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']]);
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
const res = await p;
|
||||
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not retry a submission after a network error and wraps it as NetworkError', async () => {
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([{ status: 200, throw: new TypeError('Network request failed') }]);
|
||||
const err = await client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replays a maxConcurrentRequests refusal with back-off', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = await connected();
|
||||
const refusal = { type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' };
|
||||
const fetch = mockFetch([
|
||||
{ status: 400, json: refusal },
|
||||
{ status: 400, json: refusal },
|
||||
{ status: 200, json: { methodResponses: [['Email/set', { updated: {} }, '0']] } },
|
||||
]);
|
||||
const p = client.request([['Email/set', { accountId: 'acc-1' }, '0']]);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await p;
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('surfaces the response body on a failed request and guards JSON parsing', async () => {
|
||||
const client = await connected();
|
||||
mockFetch([{ status: 500, text: 'boom happened' }]);
|
||||
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('JMAP request failed: 500 - boom happened');
|
||||
mockFetch([{ status: 200, text: '<html>not json' }]);
|
||||
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('Invalid JSON');
|
||||
});
|
||||
|
||||
it('enters a rate-limit window on 429 (HTTP-date) and short-circuits until it ends', async () => {
|
||||
const client = await connected();
|
||||
const onRateLimit = vi.fn();
|
||||
client.onRateLimit(onRateLimit);
|
||||
const fetch = mockFetch([{ status: 429, headers: { 'Retry-After': new Date(Date.now() + 20_000).toUTCString() } }]);
|
||||
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(RateLimitError);
|
||||
expect(Number.isFinite((err as RateLimitError).retryAfterMs)).toBe(true);
|
||||
expect((err as RateLimitError).retryAfterMs).toBeGreaterThan(10_000);
|
||||
expect(onRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(client.isRateLimited()).toBe(true);
|
||||
const err2 = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||
expect(err2).toBeInstanceOf(RateLimitError);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('notifies auth-failure listeners on a 401 during a request', async () => {
|
||||
const client = await connected();
|
||||
const onAuthFailure = vi.fn();
|
||||
client.onAuthFailure(onAuthFailure);
|
||||
mockFetch([{ status: 401 }]);
|
||||
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow(AuthenticationError);
|
||||
expect(onAuthFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the account when the token endpoint is transiently down', async () => {
|
||||
const client = new JMAPClient();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
await client.connectWithOAuth('https://mail.example.com', {
|
||||
accessToken: 'old',
|
||||
refreshToken: 'r',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
mockRefresh.mockRejectedValue(new TransientRefreshError('503'));
|
||||
mockFetch([{ status: 401 }]);
|
||||
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err).not.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it('fires token-refresh listeners and retries with the new bearer after a 401', async () => {
|
||||
const client = new JMAPClient();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
await client.connectWithOAuth('https://mail.example.com', {
|
||||
accessToken: 'old',
|
||||
refreshToken: 'r',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
const onRefresh = vi.fn();
|
||||
client.onTokenRefresh(onRefresh);
|
||||
mockRefresh.mockResolvedValue({
|
||||
accessToken: 'new',
|
||||
refreshToken: 'r2',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
const fetch = mockFetch([
|
||||
{ status: 401 },
|
||||
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
|
||||
]);
|
||||
await client.request([['Mailbox/get', {}, '0']]);
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer new' });
|
||||
expect(client.authHeader).toBe('Bearer new');
|
||||
});
|
||||
|
||||
it('updatePassword rewrites the stored credential', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = await connected();
|
||||
(SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mockClear();
|
||||
await client.updatePassword('new-pass');
|
||||
expect(client.authHeader).toBe(`Basic ${btoa('user:new-pass')}`);
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledTimes(1);
|
||||
const saved = JSON.parse((SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||
expect(saved.password).toBe('new-pass');
|
||||
});
|
||||
|
||||
it('exposes account-level capabilities and set/upload limits', async () => {
|
||||
const client = await connected();
|
||||
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
|
||||
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
|
||||
expect(client.getMaxObjectsInSet()).toBe(50);
|
||||
expect(client.getMaxSizeUpload()).toBe(1024);
|
||||
});
|
||||
|
||||
it('snapshot/restoreSnapshot round-trips the live connection', async () => {
|
||||
const client = await connected();
|
||||
const snap = client.snapshot();
|
||||
client.reset();
|
||||
expect(client.isConnected).toBe(false);
|
||||
client.restoreSnapshot(snap);
|
||||
expect(client.isConnected).toBe(true);
|
||||
expect(client.accountId).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ function mockFetch(responses: Array<{ status: number; json?: any; headers?: Reco
|
||||
get: (name: string) => resp.headers?.[name] ?? null,
|
||||
},
|
||||
json: async () => resp.json,
|
||||
text: async () => (resp.json === undefined ? '' : JSON.stringify(resp.json)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -90,7 +91,7 @@ describe('JMAPClient', () => {
|
||||
await client.connect('https://mail.example.com///', 'user', 'pass');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/.well-known/jmap',
|
||||
'https://mail.example.com/jmap/session',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -281,7 +282,9 @@ describe('JMAPClient', () => {
|
||||
expect(client.accountId).toBe('only-acc');
|
||||
});
|
||||
|
||||
it('should throw if no accounts at all', async () => {
|
||||
it('should throw on an empty (unauthenticated) session', async () => {
|
||||
// Stalwart returns 200 with empty accounts for invalid credentials;
|
||||
// the client surfaces that as an auth failure.
|
||||
const session = {
|
||||
...MOCK_SESSION,
|
||||
primaryAccounts: {},
|
||||
@@ -290,7 +293,7 @@ describe('JMAPClient', () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
|
||||
|
||||
await expect(client.connect('https://mail.example.com', 'user', 'pass'))
|
||||
.rejects.toThrow('No account found');
|
||||
.rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Live integration test against a real Stalwart server.
|
||||
*
|
||||
* Opt-in: skipped unless `BULWARK_LIVE_JMAP` names a reachable JMAP host, so
|
||||
* the normal suite stays hermetic. It exercises the client code paths that
|
||||
* cannot be proven with mocks - the ones where a fixture only ever encodes
|
||||
* what we already believed the server does:
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://127.0.0.1:18081 \
|
||||
* BULWARK_LIVE_USER=usera@example.org \
|
||||
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart.test.ts
|
||||
*
|
||||
* Verified against stalwartlabs/stalwart:v0.16.19.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import {
|
||||
getMailboxes,
|
||||
queryEmails,
|
||||
getFullEmails,
|
||||
getThreadEmails,
|
||||
createDraft,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
emptyMailbox,
|
||||
markMailboxAsRead,
|
||||
createMailbox,
|
||||
deleteMailbox,
|
||||
patchKeywordsForEmails,
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
// The api modules talk to the exported singleton; swap its guts for a client
|
||||
// connected to the live server.
|
||||
let client: JMAPClient;
|
||||
|
||||
async function connectSingleton(): Promise<void> {
|
||||
const mod = await import('../jmap-client');
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
// The singleton is a const binding; copy the connected state onto it.
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
}
|
||||
|
||||
live('live Stalwart', () => {
|
||||
let inbox = '';
|
||||
let drafts = '';
|
||||
let sent = '';
|
||||
let junk = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectSingleton();
|
||||
const boxes = await getMailboxes();
|
||||
const byRole = (r: string) => boxes.find((b) => b.role === r)?.id ?? '';
|
||||
inbox = byRole('inbox');
|
||||
drafts = byRole('drafts');
|
||||
sent = byRole('sent');
|
||||
junk = byRole('junk');
|
||||
expect(inbox).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
describe('session', () => {
|
||||
it('rebases the advertised session URLs onto the connected origin', () => {
|
||||
// Stalwart advertises its configured hostname (https://stwtest.local/…),
|
||||
// which is unreachable from a client that connected by IP/port. Without
|
||||
// the rewrite every request after login fails.
|
||||
const session = client.currentSession!;
|
||||
expect(session.apiUrl.startsWith(SERVER!)).toBe(true);
|
||||
expect(session.downloadUrl.startsWith(SERVER!)).toBe(true);
|
||||
// …and the RFC 6570 templates must survive the rewrite.
|
||||
expect(session.downloadUrl).toContain('{accountId}');
|
||||
expect(session.downloadUrl).toContain('{blobId}');
|
||||
expect(session.eventSourceUrl).toContain('{types}');
|
||||
});
|
||||
|
||||
it('finds the Stalwart extension in accountCapabilities, not session capabilities (native #47)', () => {
|
||||
const session = client.currentSession!;
|
||||
expect('urn:stalwart:jmap' in (session.capabilities ?? {})).toBe(false);
|
||||
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
|
||||
// The pre-fix session-level check is what made Account Security dead.
|
||||
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the advertised request limits', () => {
|
||||
expect(client.getMaxObjectsInGet()).toBeGreaterThan(0);
|
||||
expect(client.getMaxObjectsInSet()).toBeGreaterThan(0);
|
||||
expect(client.getMaxSizeUpload()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects bad credentials with AuthenticationError', async () => {
|
||||
const bad = new JMAPClient();
|
||||
await expect(bad.connect(SERVER!, USER, 'definitely-not-the-password'))
|
||||
.rejects.toMatchObject({ name: 'AuthenticationError' });
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('mail', () => {
|
||||
it('fetches the threading headers the composer needs (#234)', async () => {
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('messageId');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('references');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
|
||||
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '));
|
||||
expect(reply, 'seeded reply message missing').toBeTruthy();
|
||||
// Bare msg-ids, no angle brackets (RFC 8621 §4.1.2.3).
|
||||
expect(reply!.messageId?.[0]).toMatch(/^[^<>]+@[^<>]+$/);
|
||||
expect(reply!.references?.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(reply!.headers)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('exposes an HTML-only body as the same part in textBody and htmlBody', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const news = emails.find((e) => (e.subject ?? '').includes('August digest'));
|
||||
expect(news, 'seeded HTML-only message missing').toBeTruthy();
|
||||
const textPart = news!.textBody?.[0];
|
||||
const htmlPart = news!.htmlBody?.[0];
|
||||
// The native #46 shape: identical partId, and the part is text/html.
|
||||
expect(textPart?.partId).toBe(htmlPart?.partId);
|
||||
expect(htmlPart?.type).toBe('text/html');
|
||||
|
||||
// A plain-text message reports the same partId too - only `type` tells
|
||||
// them apart, which is why the picker must route by type.
|
||||
const plain = emails.find((e) => (e.subject ?? '') === 'Quarterly numbers');
|
||||
expect(plain!.textBody?.[0]?.partId).toBe(plain!.htmlBody?.[0]?.partId);
|
||||
expect(plain!.htmlBody?.[0]?.type).toBe('text/plain');
|
||||
}, 60_000);
|
||||
|
||||
it('returns every message of a thread, oldest first', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '))!;
|
||||
const thread = await getThreadEmails(reply.threadId);
|
||||
expect(thread.length).toBeGreaterThanOrEqual(2);
|
||||
const times = thread.map((e) => new Date(e.receivedAt).getTime());
|
||||
expect([...times].sort((a, b) => a - b)).toEqual(times);
|
||||
}, 60_000);
|
||||
|
||||
it('saves a draft into Drafts with $draft', async () => {
|
||||
const id = await createDraft(
|
||||
{
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'userb@example.org' }],
|
||||
subject: 'Draft from the live test',
|
||||
textBody: 'draft body',
|
||||
},
|
||||
drafts,
|
||||
);
|
||||
const [saved] = await getFullEmails([id]);
|
||||
expect(saved.mailboxIds[drafts]).toBe(true);
|
||||
expect(saved.keywords.$draft).toBe(true);
|
||||
// Message-ID is generated client-side from the sender's domain.
|
||||
expect(saved.messageId?.[0]).toContain('@example.org');
|
||||
await patchKeywordsForEmails([id], { $seen: true });
|
||||
const { destroyEmails } = await import('../email');
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'userb@example.org' }],
|
||||
subject: 'Live send test',
|
||||
textBody: 'sent by the live integration test',
|
||||
inReplyTo: ['<root-1@partner.example>'],
|
||||
references: ['<root-1@partner.example>'],
|
||||
},
|
||||
identities[0].id,
|
||||
sent,
|
||||
undefined,
|
||||
{ draftsMailboxId: drafts },
|
||||
);
|
||||
expect(result.emailId).toBeTruthy();
|
||||
expect(result.emailSubmissionId).toBeTruthy();
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
|
||||
const [filed] = await getFullEmails([result.emailId!]);
|
||||
// onSuccessUpdateEmail moved it out of Drafts into Sent.
|
||||
expect(filed.mailboxIds[sent]).toBe(true);
|
||||
expect(filed.mailboxIds[drafts]).toBeUndefined();
|
||||
expect(filed.keywords.$draft).toBeUndefined();
|
||||
// Threading headers went out as bare msg-ids, not the JMAP id.
|
||||
expect(filed.inReplyTo).toEqual(['root-1@partner.example']);
|
||||
expect(filed.references).toEqual(['root-1@partner.example']);
|
||||
}, 90_000);
|
||||
|
||||
it('flips $junk/$notjunk when filing spam and undoing it (#850)', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 5 });
|
||||
const target = ids[0];
|
||||
await markAsSpam([target], junk, undefined, { markRead: true });
|
||||
let [msg] = await getFullEmails([target]);
|
||||
expect(msg.mailboxIds[junk]).toBe(true);
|
||||
expect(msg.keywords.$junk).toBe(true);
|
||||
expect(msg.keywords.$seen).toBe(true);
|
||||
|
||||
await undoSpam([target], inbox);
|
||||
[msg] = await getFullEmails([target]);
|
||||
expect(msg.mailboxIds[inbox]).toBe(true);
|
||||
expect(msg.mailboxIds[junk]).toBeUndefined();
|
||||
expect(msg.keywords.$junk).toBeUndefined();
|
||||
expect(msg.keywords.$notjunk).toBe(true);
|
||||
}, 90_000);
|
||||
|
||||
it('empties a folder and marks one read in batches', async () => {
|
||||
const scratch = await createMailbox({ name: `live-test-${Date.now()}` });
|
||||
try {
|
||||
// Nothing in it yet: the loop must terminate on an empty page.
|
||||
expect(await emptyMailbox(scratch)).toBe(0);
|
||||
expect(await markMailboxAsRead(scratch)).toBe(0);
|
||||
} finally {
|
||||
await deleteMailbox(scratch);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('calendar', () => {
|
||||
it('queries a date window with the singular inCalendar filter', async () => {
|
||||
const cals = await getCalendars();
|
||||
expect(cals.length).toBeGreaterThan(0);
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString();
|
||||
const to = new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString();
|
||||
const ids = await queryEvents(cals.map((c) => c.originalId ?? c.id), from, to);
|
||||
expect(Array.isArray(ids)).toBe(true);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it('creates a timed event with a time zone and destroys it', async () => {
|
||||
const cals = await getCalendars();
|
||||
const cal = cals[0];
|
||||
const start = new Date(Date.now() + 86_400_000);
|
||||
const created = await createEvent(
|
||||
{
|
||||
title: 'Live test event',
|
||||
start: start.toISOString().replace(/\.\d+Z$/, '').replace('T', 'T'),
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
},
|
||||
cal.originalId ?? cal.id,
|
||||
);
|
||||
expect(created.id).toBeTruthy();
|
||||
await deleteEvents([created.id]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('contacts', () => {
|
||||
it('creates a card with a UID and an RFC 9553 PartialDate birthday (#644, #224)', async () => {
|
||||
const books = await getAddressBooks();
|
||||
expect(books.length).toBeGreaterThan(0);
|
||||
const book = books[0];
|
||||
const created = await createContact(
|
||||
{
|
||||
name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: 'Test' }] },
|
||||
emails: { e1: { address: `live-${Date.now()}@example.org` } },
|
||||
anniversaries: {
|
||||
a1: {
|
||||
'@type': 'Anniversary',
|
||||
kind: 'birth',
|
||||
// A raw "1985-04-12" string is rejected by Stalwart (#224).
|
||||
date: { '@type': 'PartialDate', year: 1985, month: 4, day: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
book.originalId ?? book.id,
|
||||
);
|
||||
try {
|
||||
expect(created.id).toBeTruthy();
|
||||
// createContact re-reads the card, so the caller gets a full object.
|
||||
expect(created.uid).toBeTruthy();
|
||||
expect(created.name?.components?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await deleteContacts([created.id]);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { queryRecentRecipients, searchSentRecipients } from '../recent-recipients';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('queryRecentRecipients', () => {
|
||||
it('scans Sent To/Cc newest-first and keeps the first name per address', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['m1', 'm2'] }, '0'],
|
||||
['Email/get', {
|
||||
list: [
|
||||
{ id: 'm1', to: [{ name: 'Jane Doe', email: 'jane@x.com' }], cc: [{ email: 'cc@x.com' }] },
|
||||
{ id: 'm2', to: [{ name: 'Old Jane', email: 'JANE@x.com' }, { name: 'Bob', email: 'bob@x.com' }] },
|
||||
],
|
||||
}, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await queryRecentRecipients('sent-1');
|
||||
expect(result).toEqual([
|
||||
{ name: 'Jane Doe', email: 'jane@x.com' },
|
||||
{ name: '', email: 'cc@x.com' },
|
||||
{ name: 'Bob', email: 'bob@x.com' },
|
||||
]);
|
||||
|
||||
const [query, get] = mockRequest.mock.calls[0][0];
|
||||
expect(query[1]).toMatchObject({
|
||||
accountId: 'acc-1',
|
||||
filter: { inMailbox: 'sent-1' },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: 300,
|
||||
});
|
||||
expect(get[1].properties).toEqual(['to', 'cc']);
|
||||
expect(get[1]['#ids']).toEqual({ resultOf: '0', name: 'Email/query', path: '/ids' });
|
||||
});
|
||||
|
||||
it('returns nothing without a Sent mailbox', async () => {
|
||||
expect(await queryRecentRecipients('')).toEqual([]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSentRecipients', () => {
|
||||
it('only keeps the recipients that match the query', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['m1'] }, '0'],
|
||||
['Email/get', {
|
||||
list: [{ id: 'm1', to: [{ name: 'Jane', email: 'jane@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }],
|
||||
}, '1'],
|
||||
],
|
||||
});
|
||||
expect(await searchSentRecipients('jan', 'sent-1')).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||
const query = mockRequest.mock.calls[0][0][0];
|
||||
expect(query[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'sent-1' }, { operator: 'OR', conditions: [{ to: 'jan' }, { cc: 'jan' }] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
fetchLatestRelease,
|
||||
parseSeverity,
|
||||
parseAdvisoryUrl,
|
||||
resolveApkSha256,
|
||||
stripMarkdown,
|
||||
UpdateRateLimitedError,
|
||||
type LatestRelease,
|
||||
} from '../updates';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('parseSeverity', () => {
|
||||
it('reads a severity line from the release body', () => {
|
||||
expect(parseSeverity('## Notes\nseverity: security\n- fix')).toBe('security');
|
||||
expect(parseSeverity('- Severity: Deprecated')).toBe('deprecated');
|
||||
expect(parseSeverity('nothing here')).toBe('normal');
|
||||
expect(parseSeverity('severity: bogus')).toBe('normal');
|
||||
});
|
||||
|
||||
it('reads an advisory url', () => {
|
||||
expect(parseAdvisoryUrl('advisory: https://github.com/x/y/security/advisories/GHSA-1')).toBe(
|
||||
'https://github.com/x/y/security/advisories/GHSA-1',
|
||||
);
|
||||
expect(parseAdvisoryUrl('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
it('unwraps headings, emphasis, links and bullets and drops control lines', () => {
|
||||
const out = stripMarkdown('# Title\nseverity: security\n\n- **bold** and [link](https://x.y) `code`\n> quote');
|
||||
expect(out).toBe('Title\n\n• bold and link code\nquote');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestRelease', () => {
|
||||
const release = (overrides: Record<string, unknown> = {}) => ({
|
||||
tag_name: '1.2.3',
|
||||
name: 'v1.2.3',
|
||||
html_url: 'https://github.com/bulwarkmail/native/releases/tag/1.2.3',
|
||||
published_at: '2026-01-01T00:00:00Z',
|
||||
body: '',
|
||||
assets: [
|
||||
{ name: 'app.apk', browser_download_url: 'https://x/app.apk', size: 10 },
|
||||
{ name: 'app.apk.sha256', browser_download_url: 'https://x/app.apk.sha256', size: 65 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('does not download the checksum companion during the check', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => release() }));
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
const latest = await fetchLatestRelease();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(latest?.sha256Asset?.name).toBe('app.apk.sha256');
|
||||
expect(latest?.apkSha256).toBeNull();
|
||||
expect(latest?.severity).toBe('normal');
|
||||
});
|
||||
|
||||
it('parses severity from the body', async () => {
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => release({ body: 'severity: security\nadvisory: https://a.b/c' }),
|
||||
})) as unknown as typeof fetch;
|
||||
const latest = await fetchLatestRelease();
|
||||
expect(latest?.severity).toBe('security');
|
||||
expect(latest?.advisoryUrl).toBe('https://a.b/c');
|
||||
});
|
||||
|
||||
it('throws UpdateRateLimitedError on 403/429', async () => {
|
||||
global.fetch = vi.fn(async () => ({ ok: false, status: 403 })) as unknown as typeof fetch;
|
||||
await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError);
|
||||
global.fetch = vi.fn(async () => ({ ok: false, status: 429 })) as unknown as typeof fetch;
|
||||
await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveApkSha256', () => {
|
||||
const base: LatestRelease = {
|
||||
tag: '1', name: '1', htmlUrl: '', publishedAt: '', body: '',
|
||||
apkAsset: null, sha256Asset: null, apkSha256: null, severity: 'normal', advisoryUrl: null,
|
||||
};
|
||||
|
||||
it('prefers the body hash and otherwise fetches the companion asset', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
expect(await resolveApkSha256({ ...base, apkSha256: hash })).toBe(hash);
|
||||
global.fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => `${'b'.repeat(64)} app.apk` })) as unknown as typeof fetch;
|
||||
expect(await resolveApkSha256({
|
||||
...base,
|
||||
sha256Asset: { name: 'app.apk.sha256', browser_download_url: 'https://x', size: 1 },
|
||||
})).toBe('b'.repeat(64));
|
||||
expect(await resolveApkSha256(base)).toBeNull();
|
||||
});
|
||||
});
|
||||
+136
-3
@@ -37,6 +37,30 @@ export interface AuthInfo {
|
||||
apiKeys: ApiKeyInfo[];
|
||||
}
|
||||
|
||||
export interface CryptoInfo {
|
||||
type: EncryptionType;
|
||||
publicKeyId: string | null;
|
||||
encryptOnAppend: boolean;
|
||||
allowSpamTraining: boolean;
|
||||
}
|
||||
|
||||
export interface PublicKeyInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
emailAddresses: string[];
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
// Key material (PEM / armored). Only returned by some server versions.
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface PublicKeyInput {
|
||||
description: string;
|
||||
key: string;
|
||||
emailAddresses?: string[];
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PrincipalInfo {
|
||||
displayName: string;
|
||||
emails: string[];
|
||||
@@ -48,10 +72,13 @@ type MethodResponse = [string, Record<string, any>, string];
|
||||
|
||||
// True once a live session advertises Stalwart's JMAP extension. Without it
|
||||
// none of the x:* methods exist, so the screen shows a "not available" notice.
|
||||
// Stalwart lists `urn:stalwart:jmap` only under the account's
|
||||
// `accountCapabilities` (verified on 0.16.19), never at session level, so the
|
||||
// account-level map is what has to be checked (native #47).
|
||||
export function isStalwartSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return STALWART_CAPABILITY in (session.capabilities ?? {});
|
||||
if (!session || !jmapClient.isConnected) return false;
|
||||
return jmapClient.hasAccountCapability(STALWART_CAPABILITY);
|
||||
}
|
||||
|
||||
// Pull a single method response out by its call id and throw on a JMAP method
|
||||
@@ -148,10 +175,113 @@ export async function fetchAuthInfo(): Promise<AuthInfo> {
|
||||
}
|
||||
|
||||
export async function fetchEncryptionType(): Promise<EncryptionType> {
|
||||
return (await fetchCryptoInfo()).type;
|
||||
}
|
||||
|
||||
export async function fetchCryptoInfo(): Promise<CryptoInfo> {
|
||||
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);
|
||||
const raw = (result.list?.[0]?.encryptionAtRest ?? {}) as {
|
||||
publicKey?: string | null;
|
||||
encryptOnAppend?: boolean;
|
||||
allowSpamTraining?: boolean;
|
||||
};
|
||||
return {
|
||||
type: extractEncryptionType(result.list?.[0]?.encryptionAtRest),
|
||||
publicKeyId: typeof raw.publicKey === 'string' ? raw.publicKey : null,
|
||||
encryptOnAppend: raw.encryptOnAppend === true,
|
||||
allowSpamTraining: raw.allowSpamTraining === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Stalwart's encryption at rest (`x:AccountSettings/set`
|
||||
* `encryptionAtRest`). `Aes128`/`Aes256` need a public key id from
|
||||
* {@link fetchPublicKeys}; `encryptOnAppend` also encrypts mail the user
|
||||
* files (sent/imported), `allowSpamTraining` lets the classifier read a
|
||||
* message before it is encrypted.
|
||||
*/
|
||||
export async function updateEncryptionAtRest(input: {
|
||||
type: EncryptionType;
|
||||
publicKeyId?: string | null;
|
||||
encryptOnAppend?: boolean;
|
||||
allowSpamTraining?: boolean;
|
||||
}): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
let payload: Record<string, unknown>;
|
||||
if (input.type === 'Disabled') {
|
||||
payload = { '@type': 'Disabled' };
|
||||
} else {
|
||||
if (!input.publicKeyId) throw new Error('A public key is required to enable encryption');
|
||||
payload = {
|
||||
'@type': input.type,
|
||||
publicKey: input.publicKeyId,
|
||||
encryptOnAppend: input.encryptOnAppend ?? false,
|
||||
allowSpamTraining: input.allowSpamTraining ?? false,
|
||||
};
|
||||
}
|
||||
const responses = await send([
|
||||
['x:AccountSettings/set', { accountId, update: { singleton: { encryptionAtRest: payload } } }, '0'],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to update encryption settings');
|
||||
}
|
||||
|
||||
function publicKeyFromResult(raw: Record<string, unknown>): PublicKeyInfo {
|
||||
const emails =
|
||||
raw.emailAddresses && typeof raw.emailAddresses === 'object'
|
||||
? Object.keys(raw.emailAddresses as Record<string, unknown>)
|
||||
: [];
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
description: typeof raw.description === 'string' ? raw.description : '',
|
||||
emailAddresses: emails,
|
||||
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
|
||||
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
|
||||
key: typeof raw.key === 'string' ? raw.key : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** S/MIME certificates and PGP public keys stored on the server (`x:PublicKey`). */
|
||||
export async function fetchPublicKeys(): Promise<PublicKeyInfo[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const queryResponses = await send([['x:PublicKey/query', { accountId }, '0']]);
|
||||
const query = resultFor<{ ids?: string[] }>(queryResponses, '0');
|
||||
if (!query.ids?.length) return [];
|
||||
const getResponses = await send([['x:PublicKey/get', { accountId, ids: query.ids }, '0']]);
|
||||
const got = resultFor<{ list?: Array<Record<string, unknown>> }>(getResponses, '0');
|
||||
return (got.list ?? []).map(publicKeyFromResult);
|
||||
}
|
||||
|
||||
export async function createPublicKey(input: PublicKeyInput): Promise<string> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const tmpId = 'new';
|
||||
const payload: Record<string, unknown> = {
|
||||
description: input.description,
|
||||
key: input.key,
|
||||
emailAddresses: ipsToMap(input.emailAddresses) ?? {},
|
||||
};
|
||||
if (input.expiresAt) payload.expiresAt = input.expiresAt;
|
||||
const responses = await send([['x:PublicKey/set', { accountId, create: { [tmpId]: payload } }, '0']]);
|
||||
const result = resultFor<{
|
||||
created?: Record<string, { id: string }>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
}>(responses, '0');
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to add public key');
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id) throw new Error('Server did not return the created public key');
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function removePublicKey(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([['x:PublicKey/set', { accountId, destroy: [id] }, '0']]);
|
||||
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notDestroyed?.[id];
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove public key');
|
||||
}
|
||||
|
||||
export async function fetchPrincipal(): Promise<PrincipalInfo> {
|
||||
@@ -194,6 +324,9 @@ export async function changePassword(currentPassword: string, newPassword: strin
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
|
||||
// The stored credential must follow, otherwise the next request 401s and
|
||||
// the next launch evicts the account.
|
||||
await jmapClient.updatePassword(newPassword);
|
||||
}
|
||||
|
||||
export async function updateDisplayName(displayName: string): Promise<void> {
|
||||
|
||||
+147
-59
@@ -1,64 +1,172 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import type * as LegacyFileSystemTypes from 'expo-file-system/legacy';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { getClientCertAlias, secureFetch } from '../lib/client-cert';
|
||||
|
||||
// The legacy expo-file-system API (streaming upload tasks with progress and
|
||||
// cancellation) is required lazily: it touches native globals at import time,
|
||||
// which would break every module that merely imports this file in unit tests.
|
||||
type LegacyFileSystemModule = typeof LegacyFileSystemTypes;
|
||||
let legacyFileSystem: LegacyFileSystemModule | null = null;
|
||||
async function loadLegacyFileSystem(): Promise<LegacyFileSystemModule> {
|
||||
if (!legacyFileSystem) {
|
||||
legacyFileSystem = (await import('expo-file-system/legacy')) as LegacyFileSystemModule;
|
||||
}
|
||||
return legacyFileSystem;
|
||||
}
|
||||
|
||||
export interface UploadBlobOptions {
|
||||
/** Called with bytes sent / total as the upload streams. */
|
||||
onProgress?: (sent: number, total: number) => void;
|
||||
/** Abort the in-flight upload; the promise then rejects with an AbortError. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
blobId: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('Upload cancelled');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
// JMAP servers return either the direct shape `{blobId, type, size}` or
|
||||
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
|
||||
// Stalwart has shipped both depending on version; mirror the webmail's
|
||||
// tolerant parsing so uploads don't silently lose the blobId.
|
||||
function parseUploadResponse(
|
||||
raw: Record<string, unknown>,
|
||||
accountId: string,
|
||||
fallbackSize: number,
|
||||
fallbackType: string,
|
||||
): UploadResult {
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : fallbackSize,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : fallbackType,
|
||||
};
|
||||
}
|
||||
const nested = raw[accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : fallbackSize,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : fallbackType,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
function uploadUrlFor(accountId: string): string {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
return session.uploadUrl.replace('{accountId}', encodeURIComponent(accountId));
|
||||
}
|
||||
|
||||
// Upload a local file to the JMAP upload endpoint.
|
||||
//
|
||||
// The default path streams the file from disk through expo-file-system's
|
||||
// native upload task, which reports progress and can be cancelled, so large
|
||||
// videos/PDFs from the document picker no longer have to fit in JS memory
|
||||
// (webmail #162/#333). When the user has picked a client certificate the
|
||||
// native task can't present it, so we fall back to buffering the bytes and
|
||||
// posting them through the cert-aware fetch.
|
||||
export async function uploadBlob(
|
||||
uri: string,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
options: UploadBlobOptions = {},
|
||||
): Promise<UploadResult> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const uploadUrl = uploadUrlFor(accountId);
|
||||
const contentType = type || 'application/octet-stream';
|
||||
const { onProgress, signal } = options;
|
||||
if (signal?.aborted) throw abortError();
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
const alias = await getClientCertAlias();
|
||||
if (alias) {
|
||||
return uploadBlobBuffered(uri, contentType, accountId, uploadUrl, signal);
|
||||
}
|
||||
|
||||
const LegacyFileSystem = await loadLegacyFileSystem();
|
||||
const task = LegacyFileSystem.createUploadTask(
|
||||
uploadUrl,
|
||||
uri,
|
||||
{
|
||||
httpMethod: 'POST',
|
||||
uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
},
|
||||
onProgress
|
||||
? (data) => onProgress(data.totalBytesSent, data.totalBytesExpectedToSend)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const onAbort = () => void task.cancelAsync();
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
let result: LegacyFileSystemTypes.FileSystemUploadResult | null | undefined;
|
||||
try {
|
||||
result = await task.uploadAsync();
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (signal?.aborted || !result) throw abortError();
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
const detail = (result.body || '').slice(0, 300);
|
||||
throw new Error(`Upload failed: ${result.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(result.body) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error('Upload succeeded but the server response was not JSON');
|
||||
}
|
||||
return parseUploadResponse(raw, accountId, 0, contentType);
|
||||
}
|
||||
|
||||
async function uploadBlobBuffered(
|
||||
uri: string,
|
||||
contentType: string,
|
||||
accountId: string,
|
||||
uploadUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UploadResult> {
|
||||
// Read via the file-system API so this works for both `file://` (image
|
||||
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
|
||||
// the underlying ArrayBuffer rather than the Uint8Array view because some
|
||||
// RN transports stringify typed-array bodies, which silently produces an
|
||||
// empty/garbage upload.
|
||||
const bytes = await new File(uri).bytes();
|
||||
if (signal?.aborted) throw abortError();
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
'Content-Type': contentType,
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
// JMAP servers return either the direct shape `{blobId, type, size}` or
|
||||
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
|
||||
// Stalwart has shipped both depending on version; mirror the webmail's
|
||||
// tolerant parsing so uploads don't silently lose the blobId.
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
return parseUploadResponse(raw, accountId, bytes.byteLength, contentType);
|
||||
}
|
||||
|
||||
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
|
||||
@@ -67,14 +175,12 @@ export async function uploadBlob(
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
// Blobs are account-scoped: a message imported into a shared/group account's
|
||||
// folder has to be uploaded to that account, not the user's own.
|
||||
accountId?: string,
|
||||
): Promise<UploadResult> {
|
||||
const targetAccountId = accountId ?? jmapClient.accountId;
|
||||
const uploadUrl = uploadUrlFor(targetAccountId);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
@@ -91,25 +197,7 @@ export async function uploadBytes(
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
return parseUploadResponse(raw, targetAccountId, bytes.byteLength, type);
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
|
||||
+411
-40
@@ -1,11 +1,14 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Calendar, CalendarEvent } from './types';
|
||||
import type { Calendar, CalendarEvent, CalendarRights } from './types';
|
||||
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
|
||||
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
|
||||
/**
|
||||
* IANA time zone of the device, sent as the `timeZone` argument on
|
||||
* IANA time zone of the user — their calendar time-zone setting when set
|
||||
* (#755), otherwise the device's — sent as the `timeZone` argument on
|
||||
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
|
||||
* filter values and computes utcStart/utcEnd for floating events in this zone,
|
||||
* defaulting to UTC when absent — which shifts range boundaries and
|
||||
@@ -14,12 +17,63 @@ const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
*/
|
||||
function getUserTimeZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
return getEffectiveTimeZone() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock
|
||||
* in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and
|
||||
* which Stalwart interprets in the query's `timeZone`. Accepts ISO strings
|
||||
* (with or without a zone) so callers can pass `Date.toISOString()` output.
|
||||
*/
|
||||
export function toLocalDateTime(value: string | Date, timeZone?: string): string {
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(date.getTime())) return typeof value === 'string' ? value.replace(/(\.\d+)?Z$/, '') : '';
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00';
|
||||
const hour = get('hour') === '24' ? '00' : get('hour');
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`;
|
||||
} catch {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared accounts the server rejected calendar access for — probed once, then
|
||||
// skipped for the rest of the session (the session lists every account with
|
||||
// the calendars capability, including ones that only share mail with us).
|
||||
const calendarAccessDenied = new Set<string>();
|
||||
|
||||
export function isCalendarAccessDenied(accountId: string): boolean {
|
||||
return calendarAccessDenied.has(accountId);
|
||||
}
|
||||
|
||||
export function resetCalendarAccessDenied(): void {
|
||||
calendarAccessDenied.clear();
|
||||
}
|
||||
|
||||
function isAccessDeniedError(err: unknown): boolean {
|
||||
const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType;
|
||||
return type === 'forbidden' || type === 'accountNotFound';
|
||||
}
|
||||
|
||||
/** Remember an access rejection for a shared account; returns true when it was one. */
|
||||
export function noteCalendarAccessError(accountId: string, err: unknown): boolean {
|
||||
if (!isAccessDeniedError(err)) return false;
|
||||
calendarAccessDenied.add(accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CalendarEvent/query filter restricting results to the given
|
||||
* calendars. Stalwart implements the singular `inCalendar` condition (one
|
||||
@@ -37,24 +91,106 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property
|
||||
// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single
|
||||
// object, not RFC 8984's plural array forms. Requesting the plural names
|
||||
// returns no recurrence data at all, so recurring events silently render as
|
||||
// one-off events (#13). Request the singular names and normalize below.
|
||||
const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize Stalwart's singular recurrence property names to the RFC 8984
|
||||
* plural array forms the client uses internally. JSCalendar 2.0 defines
|
||||
* recurrenceRule as a single object, but Stalwart may also return an array
|
||||
* (for events created via JMAP), so both forms are handled. Mirrors webmail's
|
||||
* normalizeStalwartPropertyNames.
|
||||
*/
|
||||
export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
const raw = event as Record<string, unknown>;
|
||||
let patched = false;
|
||||
const updates: Partial<CalendarEvent> = {};
|
||||
|
||||
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
|
||||
const val = raw.recurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
|
||||
} else {
|
||||
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
|
||||
const val = raw.excludedRecurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
|
||||
} else {
|
||||
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) return event;
|
||||
|
||||
const result = { ...event, ...updates } as T;
|
||||
delete (result as Record<string, unknown>).recurrenceRule;
|
||||
delete (result as Record<string, unknown>).excludedRecurrenceRule;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
|
||||
* properties Stalwart expects on write, dropping null rule fields. An empty
|
||||
* array (or null) becomes an explicit null so "remove recurrence" round-trips.
|
||||
* Mirrors webmail's cleanRecurrenceRules.
|
||||
*/
|
||||
function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
||||
const keyMap: Record<string, string> = {
|
||||
recurrenceRules: 'recurrenceRule',
|
||||
excludedRecurrenceRules: 'excludedRecurrenceRule',
|
||||
};
|
||||
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
|
||||
const rules = event[pluralKey];
|
||||
if (rules === undefined) continue;
|
||||
delete event[pluralKey];
|
||||
if (!Array.isArray(rules)) {
|
||||
event[singularKey] = rules;
|
||||
continue;
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
event[singularKey] = null;
|
||||
continue;
|
||||
}
|
||||
const rule = rules[0] as Record<string, unknown>;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(rule)) {
|
||||
if (v !== null) cleaned[k] = v;
|
||||
}
|
||||
event[singularKey] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
function methodResult<T = any>(res: any, index = 0): T {
|
||||
const entry = res?.methodResponses?.[index];
|
||||
if (!entry) throw new Error('JMAP: empty method response');
|
||||
if (entry[0] === 'error') {
|
||||
const err = entry[1] || {};
|
||||
throw new Error(err.description || err.type || 'JMAP method error');
|
||||
// Keep the JMAP error type so callers can tell an expected access
|
||||
// rejection (forbidden / accountNotFound on a shared account) apart from
|
||||
// a genuine failure.
|
||||
throw Object.assign(
|
||||
new Error(err.description || err.type || 'JMAP method error'),
|
||||
{ jmapErrorType: err.type as string | undefined },
|
||||
);
|
||||
}
|
||||
return entry[1] as T;
|
||||
}
|
||||
@@ -83,14 +219,28 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
// calendars, so each shared account is fetched best-effort.
|
||||
const shared = await Promise.all(
|
||||
sharedCalendarAccountIds().map(async (sharedAccountId) => {
|
||||
// Don't re-probe an account the server already rejected this session.
|
||||
if (calendarAccessDenied.has(sharedAccountId)) return [];
|
||||
try {
|
||||
const sharedRes = await jmapClient.request(
|
||||
[['Calendar/get', { accountId: sharedAccountId }, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
|
||||
return list.map((cal) => ({ ...cal, accountId: sharedAccountId, isShared: true }));
|
||||
} catch {
|
||||
// JMAP calendar ids are only unique within an account, so a shared
|
||||
// "default" calendar collides with the user's own "default". Namespace
|
||||
// the id (keeping the real id in originalId) so visibility toggles,
|
||||
// event-query routing, and create/default mutations don't cross wires.
|
||||
// Mirrors webmail's getAllCalendars.
|
||||
return list.map((cal) => ({
|
||||
...cal,
|
||||
id: `${sharedAccountId}:${cal.id}`,
|
||||
originalId: cal.id,
|
||||
accountId: sharedAccountId,
|
||||
isShared: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
noteCalendarAccessError(sharedAccountId, err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
@@ -98,26 +248,95 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
return [...own, ...shared.flat()];
|
||||
}
|
||||
|
||||
const QUERY_PAGE_SIZE = 1000;
|
||||
// Backstop against a server that keeps returning full pages; 20 pages is far
|
||||
// beyond any real calendar window and keeps a bug from looping forever.
|
||||
const QUERY_MAX_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Query event ids in a date window. `after`/`before` are instants (ISO, as
|
||||
* from `Date.toISOString()`) or LocalDateTime strings; they're sent as
|
||||
* LocalDateTime in the query's `timeZone` so Stalwart interprets the bounds
|
||||
* in the user's zone. Calendars are restricted via the singular `inCalendar`
|
||||
* condition (the plural `inCalendars` fails the whole query with
|
||||
* unsupportedFilter on Stalwart); the range and calendar conditions are
|
||||
* combined with an AND operator. Without bounds every object is returned.
|
||||
* The result set is paged: a window can hold more events than one page, and
|
||||
* a truncated page must never pass for a complete answer.
|
||||
*/
|
||||
export async function queryEvents(
|
||||
calendarIds: string[],
|
||||
_after: string,
|
||||
_before: string,
|
||||
after: string,
|
||||
before: string,
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
// Stalwart rejects after/before filters on CalendarEvent/query; date
|
||||
// filtering is done client-side. Calendars are restricted via the singular
|
||||
// `inCalendar` condition (the plural `inCalendars` fails the whole query
|
||||
// with unsupportedFilter on Stalwart).
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
|
||||
const timeZone = getUserTimeZone();
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||
const range: Record<string, string> = {};
|
||||
if (after) range.after = toLocalDateTime(after, timeZone);
|
||||
if (before) range.before = toLocalDateTime(before, timeZone);
|
||||
if (Object.keys(range).length > 0) conditions.push(range);
|
||||
const filter = conditions.length === 1
|
||||
? conditions[0]
|
||||
: conditions.length > 1
|
||||
? { operator: 'AND', conditions }
|
||||
: undefined;
|
||||
const ids: string[] = [];
|
||||
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
|
||||
const args: Record<string, unknown> = {
|
||||
accountId: account,
|
||||
limit: QUERY_PAGE_SIZE,
|
||||
position: ids.length,
|
||||
};
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (filter) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
ids.push(...batch);
|
||||
if (batch.length < QUERY_PAGE_SIZE) break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every calendar object of an account with just the properties needed to tell
|
||||
* tasks from events (see lib/calendar-component-detection). Used to find
|
||||
* tasks-only calendars and the task ids; pages through the query so accounts
|
||||
* with more than 1000 objects are covered.
|
||||
*/
|
||||
export async function scanCalendarObjects(accountId?: string): Promise<ScannedCalendarObject[]> {
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const pageSize = 1000;
|
||||
const ids: string[] = [];
|
||||
for (let position = 0; ; position += pageSize) {
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', { accountId: account, position, limit: pageSize }, '0']],
|
||||
USING,
|
||||
);
|
||||
const page = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
ids.push(...page);
|
||||
if (page.length < pageSize) break;
|
||||
}
|
||||
if (ids.length === 0) return [];
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const all: ScannedCalendarObject[] = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId: account,
|
||||
ids: ids.slice(i, i + batchSize),
|
||||
properties: SCAN_PROPERTIES,
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
all.push(...(methodResult<{ list: ScannedCalendarObject[] }>(res).list ?? []));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
|
||||
@@ -138,7 +357,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(normalizeRecurrenceProperties));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -165,11 +384,11 @@ export async function createEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
create: { 'new-event': payload },
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -182,19 +401,24 @@ export async function createEvent(
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
// The /set response echoes only server-set properties; merge them over the
|
||||
// submitted payload so the store's optimistic insert keeps recurrence data.
|
||||
return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent);
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
@@ -211,8 +435,10 @@ export async function updateEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const patch: Record<string, unknown> = { ...changes };
|
||||
cleanRecurrenceRules(patch);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
@@ -234,11 +460,15 @@ export async function deleteEvents(
|
||||
|
||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
||||
// Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress);
|
||||
// the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The
|
||||
// caller passes `repairOrganizerAddress` only for events that lack an
|
||||
// organizer (e.g. imported ones) — attendees may not modify an existing one.
|
||||
export async function rsvpEvent(
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
repairOrganizerAddress?: string | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
@@ -247,7 +477,7 @@ export async function rsvpEvent(
|
||||
const patch: Record<string, unknown> = {
|
||||
[`participants/${escaped}/participationStatus`]: status,
|
||||
};
|
||||
if (replyTo) patch.replyTo = replyTo;
|
||||
if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||
USING,
|
||||
@@ -258,8 +488,13 @@ export async function rsvpEvent(
|
||||
}
|
||||
|
||||
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
|
||||
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// `targetAccountId` routes the parse to the account that owns the blob (an
|
||||
// email in a shared mailbox lives in the sharer's account).
|
||||
export async function parseCalendarBlob(
|
||||
blobId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<Partial<CalendarEvent>[]> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
|
||||
USING,
|
||||
@@ -273,18 +508,47 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return list.map(normalizeRecurrenceProperties);
|
||||
}
|
||||
|
||||
// Download the raw text of a calendar blob (the .ics itself) so the iTIP
|
||||
// METHOD line can be read — JMAP strips the `method=` Content-Type parameter.
|
||||
// Best-effort: returns null when the download fails.
|
||||
export async function fetchCalendarBlobText(
|
||||
blobId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
await jmapClient.ensureFreshToken();
|
||||
// Lazy: blob/client-cert pull in native Expo modules, which the pure
|
||||
// JMAP layer (and its node tests) must not load eagerly.
|
||||
const [{ getDownloadUrl }, { secureFetch }] = await Promise.all([
|
||||
import('./blob'),
|
||||
import('../lib/client-cert'),
|
||||
]);
|
||||
const url = getDownloadUrl(blobId, 'invite.ics', 'text/calendar', targetAccountId);
|
||||
const res = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
return text.length > 2 * 1024 * 1024 ? null : text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
description?: string,
|
||||
): Promise<Calendar> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
|
||||
if (description?.trim()) props.description = description.trim();
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
|
||||
create: { 'new-cal': props },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -317,12 +581,119 @@ export async function setDefaultCalendar(
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Destroy a calendar. `onDestroyRemoveEvents` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
export async function deleteCalendar(id: string, targetAccountId?: string): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to delete calendar');
|
||||
}
|
||||
|
||||
export interface CalendarUpdates {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
isSubscribed?: boolean;
|
||||
}
|
||||
|
||||
// Rename / recolour / describe a calendar (Calendar/set update).
|
||||
export async function updateCalendar(
|
||||
id: string,
|
||||
updates: CalendarUpdates,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update calendar');
|
||||
}
|
||||
|
||||
// Add, update, or remove a principal's rights on an owned calendar via a
|
||||
// `shareWith/{principalId}` patch (null removes the share).
|
||||
export async function setCalendarShare(
|
||||
calendarId: string,
|
||||
principalId: string,
|
||||
rights: CalendarRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[calendarId];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update sharing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every event from a calendar. Events that live ONLY in this calendar
|
||||
* are destroyed; events also linked to another calendar are unlinked instead
|
||||
* (their `calendarIds` loses this calendar) so the user's copy elsewhere is
|
||||
* not cascade-deleted. Returns the number of events removed.
|
||||
*/
|
||||
export async function clearCalendarEvents(
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<number> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
let removed = 0;
|
||||
// Loop for accounts with more than one query page of objects.
|
||||
for (let pass = 0; pass < 20; pass++) {
|
||||
const ids = await queryEvents([calendarId], '', '', accountId);
|
||||
if (ids.length === 0) break;
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const objects: Array<{ id: string; calendarIds?: Record<string, boolean> }> = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId,
|
||||
ids: ids.slice(i, i + batchSize),
|
||||
properties: ['id', 'calendarIds'],
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
objects.push(...(methodResult<{ list: typeof objects }>(res).list ?? []));
|
||||
}
|
||||
const toDestroy: string[] = [];
|
||||
const toUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
|
||||
for (const obj of objects) {
|
||||
const others = { ...(obj.calendarIds || {}) };
|
||||
delete others[calendarId];
|
||||
if (Object.keys(others).length === 0) toDestroy.push(obj.id);
|
||||
else toUnlink.push({ id: obj.id, calendarIds: others });
|
||||
}
|
||||
if (toDestroy.length > 0) {
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, destroy: toDestroy }, '0']],
|
||||
USING,
|
||||
);
|
||||
removed += (methodResult<{ destroyed?: string[] }>(res).destroyed ?? []).length;
|
||||
}
|
||||
if (toUnlink.length > 0) {
|
||||
const update: Record<string, unknown> = {};
|
||||
for (const { id, calendarIds } of toUnlink) update[id] = { calendarIds };
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update }, '0']],
|
||||
USING,
|
||||
);
|
||||
removed += Object.keys(methodResult<{ updated?: Record<string, unknown> }>(res).updated ?? {}).length;
|
||||
}
|
||||
if (toDestroy.length === 0 && toUnlink.length === 0) break;
|
||||
if (ids.length < 1000) break;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
+289
-60
@@ -1,9 +1,25 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { ContactCard, AddressBook } from './types';
|
||||
import { generateUUID } from '../lib/uuid';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
|
||||
|
||||
// Stalwart's default property list for AddressBook/get omits shareWith, so
|
||||
// existing shares would be invisible after a fresh login (webmail #257).
|
||||
const ADDRESS_BOOK_PROPERTIES = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'sortOrder',
|
||||
'isDefault',
|
||||
'isSubscribed',
|
||||
'shareWith',
|
||||
'myRights',
|
||||
] as const;
|
||||
|
||||
type SetError = { description?: string; type?: string; properties?: string[] };
|
||||
|
||||
function methodResult<T = any>(res: any, index = 0): T {
|
||||
const entry = res?.methodResponses?.[index];
|
||||
if (!entry) throw new Error('JMAP: empty method response');
|
||||
@@ -14,40 +30,139 @@ function methodResult<T = any>(res: any, index = 0): T {
|
||||
return entry[1] as T;
|
||||
}
|
||||
|
||||
export async function getAddressBooks(): Promise<AddressBook[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
function setErrorMessage(err: SetError | undefined, fallback: string): string {
|
||||
if (!err) return fallback;
|
||||
const detail = err.description || err.type || fallback;
|
||||
return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
|
||||
* falling back to the mail primary. Contacts can live in a different account
|
||||
* than mail on some servers.
|
||||
*/
|
||||
export function getContactsAccountId(): string {
|
||||
const id = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS];
|
||||
return id || jmapClient.accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account that may hold address books: the contacts primary first, then
|
||||
* accounts that advertise the contacts capability or are non-personal (shared
|
||||
* / group) accounts - Stalwart doesn't always advertise capabilities on group
|
||||
* accounts even when they have contact resources. Mirrors the webmail's
|
||||
* getContactCapableAccountIds().
|
||||
*/
|
||||
export function getContactCapableAccountIds(): string[] {
|
||||
const primaryId = getContactsAccountId();
|
||||
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||
const out: string[] = [];
|
||||
for (const [id, account] of Object.entries(accounts)) {
|
||||
if (id === primaryId) continue;
|
||||
if (account.accountCapabilities?.[CAPABILITIES.CONTACTS] || account.isPersonal === false) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return [primaryId, ...out];
|
||||
}
|
||||
|
||||
function accountName(accountId: string): string {
|
||||
return jmapClient.currentSession?.accounts?.[accountId]?.name || accountId;
|
||||
}
|
||||
|
||||
/** Namespace an id from a non-primary account as `<accountId>:<id>`. */
|
||||
export function namespaceId(accountId: string, id: string): string {
|
||||
return `${accountId}:${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip client-only metadata before a card goes to the server. Shared-account
|
||||
* cards carry namespaced ids and account tags that JMAP would reject as
|
||||
* unknown properties.
|
||||
*/
|
||||
export function stripClientFields<T extends Partial<ContactCard>>(contact: T): Partial<ContactCard> {
|
||||
const {
|
||||
originalId: _oid, accountId: _aid, accountName: _an, isShared: _is,
|
||||
...rest
|
||||
} = contact as ContactCard;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function getAddressBooks(accountId?: string): Promise<AddressBook[]> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/get', { accountId }, '0']],
|
||||
[['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ list: AddressBook[] }>(res).list ?? [];
|
||||
}
|
||||
|
||||
export async function queryContacts(
|
||||
filter?: { text?: string; inAddressBook?: string },
|
||||
limit = 1000,
|
||||
): Promise<string[]> {
|
||||
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
|
||||
// Client sorts by display name after fetch.
|
||||
const accountId = jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId, limit };
|
||||
if (filter && Object.keys(filter).length > 0) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
/**
|
||||
* Address books across every contacts-capable account. Books from non-primary
|
||||
* (shared / group) accounts get a namespaced id plus `accountId`, `accountName`
|
||||
* and `isShared`; a failing shared account never hides the user's own books.
|
||||
*/
|
||||
export async function getAllAddressBooks(): Promise<AddressBook[]> {
|
||||
const primaryId = getContactsAccountId();
|
||||
const all: AddressBook[] = [];
|
||||
for (const accountId of getContactCapableAccountIds()) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
try {
|
||||
const books = await getAddressBooks(accountId);
|
||||
all.push(...books.map((book) => ({
|
||||
...book,
|
||||
id: isPrimary ? book.id : namespaceId(accountId, book.id),
|
||||
originalId: book.id,
|
||||
accountId,
|
||||
accountName: isPrimary ? (jmapClient.username || accountName(accountId)) : accountName(accountId),
|
||||
isShared: !isPrimary,
|
||||
})));
|
||||
} catch (err) {
|
||||
if (isPrimary) throw err;
|
||||
console.warn(`[contacts] address books for shared account ${accountId} failed`, err);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function getContacts(ids: string[]): Promise<ContactCard[]> {
|
||||
export async function queryContacts(
|
||||
filter?: { text?: string; inAddressBook?: string },
|
||||
limit?: number,
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
|
||||
// Client sorts by display name after fetch. The server may clamp `limit`
|
||||
// below what we ask for, so walk `position` until the total is reached.
|
||||
const account = accountId || getContactsAccountId();
|
||||
const batchSize = limit ?? jmapClient.getMaxObjectsInGet();
|
||||
const allIds: string[] = [];
|
||||
let position = 0;
|
||||
for (;;) {
|
||||
const args: Record<string, unknown> = { accountId: account, position, limit: batchSize };
|
||||
if (filter && Object.keys(filter).length > 0) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ ids?: string[]; total?: number }>(res);
|
||||
const ids = result.ids ?? [];
|
||||
allIds.push(...ids);
|
||||
const total = typeof result.total === 'number' ? result.total : -1;
|
||||
if (ids.length === 0 || ids.length < batchSize || (total >= 0 && allIds.length >= total)) break;
|
||||
position += ids.length;
|
||||
}
|
||||
return allIds;
|
||||
}
|
||||
|
||||
export async function getContacts(ids: string[], accountId?: string): Promise<ContactCard[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const account = accountId || getContactsAccountId();
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const all: ContactCard[] = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const batch = ids.slice(i, i + batchSize);
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/get', { accountId, ids: batch }, '0']],
|
||||
[['ContactCard/get', { accountId: account, ids: batch }, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
|
||||
@@ -56,93 +171,207 @@ export async function getContacts(ids: string[]): Promise<ContactCard[]> {
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Tag a card fetched from `accountId` with the client-side account metadata. */
|
||||
function tagContact(contact: ContactCard, accountId: string, isPrimary: boolean): ContactCard {
|
||||
if (isPrimary) return contact;
|
||||
const addressBookIds = contact.addressBookIds
|
||||
? Object.fromEntries(
|
||||
Object.entries(contact.addressBookIds).map(([bookId, v]) => [namespaceId(accountId, bookId), v]),
|
||||
)
|
||||
: contact.addressBookIds;
|
||||
return {
|
||||
...contact,
|
||||
id: namespaceId(accountId, contact.id),
|
||||
originalId: contact.id,
|
||||
addressBookIds,
|
||||
accountId,
|
||||
accountName: accountName(accountId),
|
||||
isShared: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact cards across every contacts-capable account, namespaced like
|
||||
* getAllAddressBooks. A failing shared account is skipped; a failing primary
|
||||
* account throws so the store can surface the error.
|
||||
*/
|
||||
export async function getAllContacts(): Promise<ContactCard[]> {
|
||||
const primaryId = getContactsAccountId();
|
||||
const all: ContactCard[] = [];
|
||||
for (const accountId of getContactCapableAccountIds()) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
try {
|
||||
const ids = await queryContacts(undefined, undefined, accountId);
|
||||
const cards = await getContacts(ids, accountId);
|
||||
all.push(...cards.map((card) => tagContact(card, accountId, isPrimary)));
|
||||
} catch (err) {
|
||||
if (isPrimary) throw err;
|
||||
console.warn(`[contacts] contacts for shared account ${accountId} failed`, err);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function getContact(id: string, accountId?: string): Promise<ContactCard | null> {
|
||||
const list = await getContacts([id], accountId);
|
||||
return list[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createContact(
|
||||
contact: Partial<ContactCard>,
|
||||
addressBookId: string,
|
||||
accountId?: string,
|
||||
): Promise<ContactCard> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const account = accountId || getContactsAccountId();
|
||||
const data = stripClientFields(contact);
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/set', {
|
||||
accountId,
|
||||
accountId: account,
|
||||
create: {
|
||||
'new-contact': { ...contact, addressBookIds: { [addressBookId]: true } },
|
||||
'new-contact': {
|
||||
...data,
|
||||
// Stalwart stores the card without one if omitted (#644), which
|
||||
// breaks group membership and CardDAV round-trips.
|
||||
uid: data.uid || `urn:uuid:${generateUUID()}`,
|
||||
addressBookIds: { [addressBookId]: true },
|
||||
},
|
||||
},
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-contact'];
|
||||
const result = methodResult<{
|
||||
created?: Record<string, Partial<ContactCard>>;
|
||||
notCreated?: Record<string, SetError>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-contact'];
|
||||
if (!created?.id) {
|
||||
throw new Error(setErrorMessage(result.notCreated?.['new-contact'], 'Failed to create contact'));
|
||||
}
|
||||
// The set response only carries server-set properties (id, created,
|
||||
// updated...). Re-fetch the full card so the store appends a complete row
|
||||
// instead of an "Unnamed" stub; fall back to a client-side merge.
|
||||
try {
|
||||
const full = await getContact(created.id, account);
|
||||
if (full) return full;
|
||||
} catch {
|
||||
// fall through to the merge below
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
uid: data.uid,
|
||||
...created,
|
||||
id: created.id,
|
||||
addressBookIds: { [addressBookId]: true },
|
||||
} as ContactCard;
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
id: string,
|
||||
changes: Partial<ContactCard>,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['ContactCard/set', { accountId, update: { [id]: changes } }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteContacts(ids: string[]): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['ContactCard/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAddressBook(name: string): Promise<AddressBook> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
|
||||
[['ContactCard/set', { accountId: account, update: { [id]: stripClientFields(changes) } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err, 'Failed to update contact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy cards. Returns the ids the server actually destroyed; throws when
|
||||
* nothing could be destroyed (with the first server description) so a
|
||||
* single-card delete surfaces the reason.
|
||||
*/
|
||||
export async function deleteContacts(ids: string[], accountId?: string): Promise<string[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/set', { accountId: account, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
destroyed?: string[];
|
||||
notDestroyed?: Record<string, SetError>;
|
||||
}>(res);
|
||||
const failed = result.notDestroyed ?? {};
|
||||
// Servers that omit `destroyed` still honoured everything not listed as failed.
|
||||
const destroyed = result.destroyed ?? ids.filter((id) => !(id in failed));
|
||||
if (destroyed.length === 0) {
|
||||
const firstFailure = Object.values(failed)[0];
|
||||
throw new Error(setErrorMessage(firstFailure, 'Failed to delete contact'));
|
||||
}
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
export async function createAddressBook(name: string, accountId?: string): Promise<AddressBook> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId: account, create: { 'new-book': { name } } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, AddressBook>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
notCreated?: Record<string, SetError>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-book'];
|
||||
if (created) return { ...created, name };
|
||||
const err = result.notCreated?.['new-book'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create address book');
|
||||
throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book'));
|
||||
}
|
||||
|
||||
export async function updateAddressBook(
|
||||
id: string,
|
||||
updates: Partial<AddressBook>,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Only forward server-settable properties.
|
||||
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
|
||||
const account = accountId || getContactsAccountId();
|
||||
// Only forward server-settable properties. `isDefault` is deliberately not
|
||||
// among them: it is read-only in AddressBook/set and including it fails the
|
||||
// whole update with invalidProperties - use setDefaultAddressBook instead.
|
||||
const { name, description, sortOrder } = updates as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (description !== undefined) patch.description = description;
|
||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
|
||||
[['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
|
||||
if (err) throw new Error(setErrorMessage(err, 'Failed to update address book'));
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
/**
|
||||
* Mark an address book as the account default. `isDefault` is read-only in
|
||||
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
|
||||
* request argument instead (same shape as Calendar/set).
|
||||
*/
|
||||
export async function setDefaultAddressBook(id: string, accountId?: string): Promise<void> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
|
||||
[['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
|
||||
const err = result.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
|
||||
if (err) throw new Error(setErrorMessage(err, 'Failed to delete address book'));
|
||||
}
|
||||
|
||||
/** Fetch all contact cards that live in a specific address book. */
|
||||
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
|
||||
const ids = await queryContacts({ inAddressBook: addressBookId });
|
||||
return getContacts(ids);
|
||||
export async function getContactsInBook(addressBookId: string, accountId?: string): Promise<ContactCard[]> {
|
||||
const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId);
|
||||
return getContacts(ids, accountId);
|
||||
}
|
||||
|
||||
+865
-265
File diff suppressed because it is too large
Load Diff
+106
-21
@@ -1,7 +1,8 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode, FileNodeRights, Principal } from './types';
|
||||
import { getDownloadUrl, uploadBlob } from './blob';
|
||||
import type { FileNode, FileNodeRights, JMAPAccountInfo, Principal } from './types';
|
||||
import { getDownloadUrl, uploadBlob, type UploadBlobOptions } from './blob';
|
||||
import { decodeFileNodeName } from '../lib/filenode-name';
|
||||
|
||||
// A FileNode is a folder (container) only when it has no blob content — the
|
||||
// server stores it with `file == null`. Sending a blobId/type/size on create
|
||||
@@ -15,22 +16,58 @@ export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
|
||||
|
||||
// Stalwart omits shareWith/myRights from FileNode/get unless they are
|
||||
// requested explicitly, so the share sheet and indicators must name them here.
|
||||
// The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode);
|
||||
// asking for a property the server doesn't know silently yields undefined,
|
||||
// which is how "updated" left every date blank (#700).
|
||||
const FILE_NODE_PROPERTIES = [
|
||||
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
|
||||
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
|
||||
'shareWith', 'myRights',
|
||||
];
|
||||
|
||||
// RFC 9670 sharing is only usable when the server advertises the exact
|
||||
// `principals:owner` capability, either in the session or on the account
|
||||
// (Stalwart places it in accountCapabilities).
|
||||
export function supportsSharing(): boolean {
|
||||
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
|
||||
return (
|
||||
jmapClient.hasCapability(CAPABILITIES.PRINCIPALS_OWNER) ||
|
||||
jmapClient.hasAccountCapability(CAPABILITIES.PRINCIPALS_OWNER, filesAccountId())
|
||||
);
|
||||
}
|
||||
|
||||
function filesAccountId(): string {
|
||||
export function filesAccountId(): string {
|
||||
return (
|
||||
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
|
||||
jmapClient.accountId
|
||||
);
|
||||
}
|
||||
|
||||
// Gate on the ACCOUNT capability, not only the server-wide session
|
||||
// capability. A server can advertise urn:ietf:params:jmap:filenode while a
|
||||
// specific account has its jmap-file-node-* permissions revoked, in which case
|
||||
// the capability is absent from that account's accountCapabilities and every
|
||||
// FileNode action fails with an authorization error (#563). Non-personal
|
||||
// (shared/group) accounts don't always advertise per-account, so treat those
|
||||
// as capable. Mirrors the webmail's `supportsFiles`.
|
||||
export function accountSupportsFiles(
|
||||
account: JMAPAccountInfo | undefined,
|
||||
sessionCapabilities: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!sessionCapabilities || !(CAPABILITIES.FILES in sessionCapabilities)) return false;
|
||||
if (!account) return false;
|
||||
return account.accountCapabilities?.[CAPABILITIES.FILES] != null || !account.isPersonal;
|
||||
}
|
||||
|
||||
export function supportsFiles(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return accountSupportsFiles(session.accounts?.[filesAccountId()], session.capabilities);
|
||||
}
|
||||
|
||||
/** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */
|
||||
export function getMaxSizeUpload(): number {
|
||||
return jmapClient.getMaxSizeUpload();
|
||||
}
|
||||
|
||||
function fileUsing(): string[] {
|
||||
const using: string[] = [CAPABILITIES.CORE];
|
||||
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
|
||||
@@ -68,7 +105,10 @@ export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
if (!result || result[0] === 'error') {
|
||||
throw new Error(result?.[1]?.description || 'FileNode list failed');
|
||||
}
|
||||
return (result[1].list ?? []) as FileNode[];
|
||||
return ((result[1].list ?? []) as FileNode[]).map((node) => ({
|
||||
...node,
|
||||
name: decodeFileNodeName(node.name),
|
||||
}));
|
||||
}
|
||||
|
||||
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
|
||||
@@ -107,6 +147,7 @@ export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||
for (const node of (result[1].list ?? []) as FileNode[]) {
|
||||
all.push({
|
||||
...node,
|
||||
name: decodeFileNodeName(node.name),
|
||||
id: isPrimary ? node.id : `${accountId}:${node.id}`,
|
||||
parentId: node.parentId == null
|
||||
? null
|
||||
@@ -163,6 +204,12 @@ export async function renameFileNode(id: string, newName: string): Promise<void>
|
||||
await updateFileNode(id, { name: newName });
|
||||
}
|
||||
|
||||
// Re-parent a node. `null` moves it to the drive root (the property must be
|
||||
// sent explicitly as null, omitting it would leave the node where it is).
|
||||
export async function moveFileNode(id: string, parentId: string | null): Promise<void> {
|
||||
await updateFileNode(id, { parentId });
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = filesAccountId();
|
||||
@@ -191,37 +238,75 @@ export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
// Stalwart caps the stored MIME type; very long types fail the create.
|
||||
function safeMimeType(type: string | undefined, fallback: string): string {
|
||||
const t = type || fallback || 'application/octet-stream';
|
||||
return t.length > 30 ? 'application/octet-stream' : t;
|
||||
}
|
||||
|
||||
async function createFileNodeFromBlob(
|
||||
name: string,
|
||||
mimeType: string,
|
||||
blobId: string,
|
||||
type: string,
|
||||
size: number | undefined,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
const accountId = filesAccountId();
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const type = blob.type || mimeType || 'application/octet-stream';
|
||||
const props: Record<string, unknown> = {
|
||||
name,
|
||||
// Stalwart caps the stored MIME type; very long types fail the create.
|
||||
type: type.length > 30 ? 'application/octet-stream' : type,
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
};
|
||||
const props: Record<string, unknown> = { name, type, blobId };
|
||||
if (size != null) props.size = size;
|
||||
if (parentId !== null) props.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
const created = result.created?.['new-file'];
|
||||
const result = res.methodResponses[0];
|
||||
if (!result || result[0] === 'error') {
|
||||
throw new Error(result?.[1]?.description || 'FileNode/set create failed');
|
||||
}
|
||||
const created = result[1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-file'];
|
||||
const err = result[1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Upload failed');
|
||||
}
|
||||
return { ...props, ...created } as FileNode;
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
parentId: string | null,
|
||||
options: UploadBlobOptions = {},
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType, options);
|
||||
return createFileNodeFromBlob(
|
||||
name,
|
||||
blob.blobId,
|
||||
safeMimeType(blob.type, mimeType),
|
||||
blob.size,
|
||||
parentId,
|
||||
);
|
||||
}
|
||||
|
||||
// Copy a file by creating a new node that references the same blob — no
|
||||
// bytes are re-uploaded (webmail `copyFileNode`). Folders have no blob and
|
||||
// cannot be duplicated this way.
|
||||
export async function copyFileNode(
|
||||
node: FileNode,
|
||||
parentId: string | null,
|
||||
newName: string = node.name,
|
||||
): Promise<FileNode> {
|
||||
if (!node.blobId) throw new Error('Folders cannot be duplicated');
|
||||
return createFileNodeFromBlob(
|
||||
newName,
|
||||
node.blobId,
|
||||
safeMimeType(node.type, 'application/octet-stream'),
|
||||
node.size,
|
||||
parentId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sharing (RFC 9670) ────────────────────────────────────
|
||||
|
||||
// Add, update, or remove a principal's rights on an owned FileNode (file or
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Serialises the FIRST request that touches a lazily-initialised server
|
||||
* collection, per account. Port of the webmail's lib/jmap/first-touch-gate.ts.
|
||||
*
|
||||
* Stalwart creates an account's default calendar (and default address book) on
|
||||
* the first access to an account that has none. On a single node that creation
|
||||
* is guarded by the node's in-memory resource cache, but in clustered / multi-
|
||||
* node deployments each node builds its own cache, so concurrent first-touch
|
||||
* requests that land on different nodes each see "no calendars" and each create
|
||||
* a default - leaving the user with two identical "Personal" calendars, one of
|
||||
* which is flagged `isDefault` and can't be removed from the UI (#907).
|
||||
*
|
||||
* The app's login fans out `Calendar/get`, `CalendarEvent/query` (and
|
||||
* `AddressBook/get`, `ContactCard/query`) within a few milliseconds of each
|
||||
* other, which makes that race trivially winnable. This gate holds every later
|
||||
* request on a (collection, account) pair until the first one has settled -
|
||||
* success or failure - after which requests flow with no further coordination.
|
||||
* Method calls outside the gated collections are never delayed.
|
||||
*/
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
export type GatedCollection = 'calendar' | 'contacts';
|
||||
|
||||
const COLLECTION_PREFIXES: ReadonlyArray<readonly [GatedCollection, string]> = [
|
||||
['calendar', 'Calendar/'],
|
||||
['calendar', 'CalendarEvent/'],
|
||||
['calendar', 'CalendarEventNotification/'],
|
||||
['contacts', 'AddressBook/'],
|
||||
['contacts', 'ContactCard/'],
|
||||
];
|
||||
|
||||
function collectionOf(method: string): GatedCollection | null {
|
||||
for (const [collection, prefix] of COLLECTION_PREFIXES) {
|
||||
if (method.startsWith(prefix)) return collection;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `collection:accountId` keys a request touches. Calls without a string
|
||||
* accountId are skipped - they'd be rejected by the server anyway.
|
||||
*/
|
||||
export function gateKeysFor(methodCalls: ReadonlyArray<MethodCall>): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const call of methodCalls) {
|
||||
const collection = collectionOf(call[0]);
|
||||
const accountId = call[1]?.accountId;
|
||||
if (collection && typeof accountId === 'string' && accountId) {
|
||||
keys.add(`${collection}:${accountId}`);
|
||||
}
|
||||
}
|
||||
return Array.from(keys);
|
||||
}
|
||||
|
||||
export class FirstTouchGate {
|
||||
private settled = new Set<string>();
|
||||
private inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Run `send` for `methodCalls`, delaying it while another request is the
|
||||
* first to touch one of the collections it uses. Resolves/rejects with
|
||||
* whatever `send` does.
|
||||
*/
|
||||
async run<T>(methodCalls: ReadonlyArray<MethodCall>, send: () => Promise<T>): Promise<T> {
|
||||
const pending = gateKeysFor(methodCalls).filter((key) => !this.settled.has(key));
|
||||
if (pending.length === 0) return send();
|
||||
|
||||
const waits = pending
|
||||
.map((key) => this.inFlight.get(key))
|
||||
.filter((p): p is Promise<void> => p !== undefined);
|
||||
if (waits.length > 0) {
|
||||
await Promise.all(waits);
|
||||
// Those keys are settled now; anything still pending belongs to another
|
||||
// collection/account this request happens to be first on.
|
||||
return this.run(methodCalls, send);
|
||||
}
|
||||
|
||||
// First touch for every pending key: run it and let the others queue.
|
||||
const result = send();
|
||||
const done = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
for (const key of pending) this.inFlight.set(key, done);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
for (const key of pending) {
|
||||
this.settled.add(key);
|
||||
this.inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget everything - e.g. after reconnecting as a different account. */
|
||||
reset(): void {
|
||||
this.settled.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
}
|
||||
+35
-18
@@ -1,28 +1,43 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { assertSetResult, requireMethodResult } from './jmap-result';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Identity } from './types';
|
||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
|
||||
export async function getIdentities(): Promise<Identity[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Identity/get', { accountId }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
|
||||
|
||||
// A display name that carries an address or a line break produces a malformed
|
||||
// `From:` on every message sent with the identity (#672); reduce it to the
|
||||
// bare name before it reaches the server.
|
||||
function sanitizeIdentityPatch(identity: Partial<Identity>): Partial<Identity> {
|
||||
if (typeof identity.name !== 'string') return identity;
|
||||
return { ...identity, name: sanitizeDisplayName(identity.name) };
|
||||
}
|
||||
|
||||
export async function getIdentities(accountIdOverride?: string): Promise<Identity[]> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([['Identity/get', { accountId }, '0']], USING);
|
||||
const body = requireMethodResult<{ list?: Identity[] }>(res, '0', 'Identity/get');
|
||||
return body.list ?? [];
|
||||
}
|
||||
|
||||
export async function createIdentity(
|
||||
identity: Partial<Identity>,
|
||||
): Promise<Identity> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const cid = 'new-identity';
|
||||
const res = await jmapClient.request(
|
||||
[['Identity/set', {
|
||||
accountId,
|
||||
create: { 'new-identity': identity },
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
[['Identity/set', { accountId, create: { [cid]: sanitizeIdentityPatch(identity) } }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-identity'];
|
||||
const body = requireMethodResult(res, '0', 'Identity/set');
|
||||
assertSetResult(body, [cid], 'identity');
|
||||
const created = body.created?.[cid] as Partial<Identity> | undefined;
|
||||
if (!created?.id) throw new Error('Identity/set returned no id');
|
||||
// The create echo carries only server-set properties; re-read the full
|
||||
// object so the caller gets name/email/signature back.
|
||||
const all = await getIdentities();
|
||||
return all.find((i) => i.id === created.id) ?? ({ ...identity, ...created } as Identity);
|
||||
}
|
||||
|
||||
export async function updateIdentity(
|
||||
@@ -30,16 +45,18 @@ export async function updateIdentity(
|
||||
changes: Partial<Identity>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['Identity/set', { accountId, update: { [id]: changes } }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
const res = await jmapClient.request(
|
||||
[['Identity/set', { accountId, update: { [id]: sanitizeIdentityPatch(changes) } }, '0']],
|
||||
USING,
|
||||
);
|
||||
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
|
||||
}
|
||||
|
||||
export async function deleteIdentity(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
const res = await jmapClient.request(
|
||||
[['Identity/set', { accountId, destroy: [id] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
USING,
|
||||
);
|
||||
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
|
||||
}
|
||||
|
||||
+659
-79
@@ -8,12 +8,33 @@ import type {
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
import {
|
||||
refreshOAuthAccessToken,
|
||||
TransientRefreshError,
|
||||
type OAuthTokens,
|
||||
type OAuthTokenSource,
|
||||
} from '../lib/oauth';
|
||||
import { FirstTouchGate } from './first-touch-gate';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
// in-flight requests don't race the expiry window.
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
// Deadline on response headers for an ordinary JMAP call, and for blob
|
||||
// transfers (uploads/downloads can legitimately take minutes on cellular).
|
||||
// Mirrors the webmail's REQUEST_TIMEOUT_MS / blob timeout (#702): iOS suspends
|
||||
// sockets in the background and hands back dead pooled connections on resume;
|
||||
// without a deadline a send or save hangs forever.
|
||||
export const REQUEST_TIMEOUT_MS = 30_000;
|
||||
export const BLOB_TIMEOUT_MS = 300_000;
|
||||
|
||||
// Base back-off per attempt when the server refuses a request for being one
|
||||
// too many in parallel (maxConcurrentRequests, #780).
|
||||
const CONCURRENT_REQUEST_RETRY_DELAYS_MS = [200, 400, 800];
|
||||
|
||||
// Delay before the single transient-network retry of an idempotent request.
|
||||
const TRANSIENT_RETRY_DELAY_MS = 1000;
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
@@ -34,12 +55,76 @@ export interface StoredCredentials {
|
||||
expiresAt?: number;
|
||||
tokenEndpoint?: string;
|
||||
clientId?: string;
|
||||
tokenSource?: OAuthTokenSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 8620 §3.6.1 limit error naming the server's parallel-request ceiling.
|
||||
* Stalwart refuses the surplus of a burst with 400 jmap:error:limit /
|
||||
* maxConcurrentRequests BEFORE any method runs, so the request is safe to
|
||||
* replay - even a /set.
|
||||
*/
|
||||
export function isConcurrentRequestRefusal(status: number, body: string): boolean {
|
||||
if (status !== 400) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { type?: unknown; limit?: unknown } | null;
|
||||
return (
|
||||
parsed?.type === 'urn:ietf:params:jmap:error:limit' &&
|
||||
parsed?.limit === 'maxConcurrentRequests'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds or HTTP-date `Retry-After`, capped at 5 minutes, defaulting to 60 s
|
||||
* when missing or unparseable (an HTTP-date used to turn into `NaN` ms).
|
||||
*/
|
||||
export function parseRetryAfter(header: string | null): number {
|
||||
if (!header) return 60_000;
|
||||
const seconds = Number(header);
|
||||
if (!Number.isNaN(seconds) && seconds > 0) {
|
||||
return Math.min(seconds * 1000, 300_000);
|
||||
}
|
||||
const date = Date.parse(header);
|
||||
if (!Number.isNaN(date)) {
|
||||
const ms = date - Date.now();
|
||||
return ms > 0 ? Math.min(ms, 300_000) : 60_000;
|
||||
}
|
||||
return 60_000;
|
||||
}
|
||||
|
||||
// Methods whose bodies must never be replayed automatically: a retried
|
||||
// EmailSubmission/set would send the mail twice.
|
||||
function hasNonIdempotentMethod(methodCalls: ReadonlyArray<JMAPMethodCall>): boolean {
|
||||
return methodCalls.some(
|
||||
([name]) =>
|
||||
name === 'EmailSubmission/set' ||
|
||||
name === 'Email/import' ||
|
||||
name === 'Blob/upload',
|
||||
);
|
||||
}
|
||||
|
||||
function fetchSessionDescribes(session: unknown): session is JMAPSession {
|
||||
if (!session || typeof session !== 'object') return false;
|
||||
const s = session as Partial<JMAPSession>;
|
||||
return typeof s.apiUrl === 'string';
|
||||
}
|
||||
|
||||
// Shared response helpers live in ./jmap-result (dependency-free) and are
|
||||
// re-exported here for convenience.
|
||||
export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result';
|
||||
|
||||
export class JMAPClient {
|
||||
private session: JMAPSession | null = null;
|
||||
private credentials: StoredCredentials | null = null;
|
||||
private _accountId: string | null = null;
|
||||
private firstTouchGate = new FirstTouchGate();
|
||||
private rateLimitedUntil = 0;
|
||||
private authFailureListeners = new Set<(err: AuthenticationError) => void>();
|
||||
private rateLimitListeners = new Set<(retryAfterMs: number) => void>();
|
||||
private tokenRefreshListeners = new Set<() => void>();
|
||||
|
||||
get accountId(): string {
|
||||
if (!this._accountId) {
|
||||
@@ -71,6 +156,48 @@ export class JMAPClient {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
|
||||
// ── Lifecycle hooks ───────────────────────────────────
|
||||
// The auth store subscribes to auth failures so a revoked token/password
|
||||
// leads back to the login screen instead of a dead session; the offline
|
||||
// banner subscribes to rate-limit windows; the live-update layer recreates
|
||||
// the SSE stream whenever the bearer token rotates.
|
||||
|
||||
onAuthFailure(listener: (err: AuthenticationError) => void): () => void {
|
||||
this.authFailureListeners.add(listener);
|
||||
return () => this.authFailureListeners.delete(listener);
|
||||
}
|
||||
|
||||
onRateLimit(listener: (retryAfterMs: number) => void): () => void {
|
||||
this.rateLimitListeners.add(listener);
|
||||
return () => this.rateLimitListeners.delete(listener);
|
||||
}
|
||||
|
||||
onTokenRefresh(listener: () => void): () => void {
|
||||
this.tokenRefreshListeners.add(listener);
|
||||
return () => this.tokenRefreshListeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyAuthFailure(err: AuthenticationError): void {
|
||||
for (const l of this.authFailureListeners) {
|
||||
try { l(err); } catch { /* listener errors must not mask the request */ }
|
||||
}
|
||||
}
|
||||
|
||||
isRateLimited(): boolean {
|
||||
return this.rateLimitedUntil > Date.now();
|
||||
}
|
||||
|
||||
rateLimitRemainingMs(): number {
|
||||
return Math.max(0, this.rateLimitedUntil - Date.now());
|
||||
}
|
||||
|
||||
private setRateLimited(retryAfterMs: number): void {
|
||||
this.rateLimitedUntil = Date.now() + retryAfterMs;
|
||||
for (const l of this.rateLimitListeners) {
|
||||
try { l(retryAfterMs); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authentication ────────────────────────────────────
|
||||
|
||||
// Public so blob upload/download helpers (which can't go through the JMAP
|
||||
@@ -85,13 +212,28 @@ export class JMAPClient {
|
||||
return `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
async connect(serverUrl: string, username: string, password: string): Promise<JMAPSession> {
|
||||
// Normalise URL
|
||||
/**
|
||||
* Password login. When the server answers 402 "MFA code required" and no
|
||||
* `totp` was supplied, throws `TotpRequiredError` so the UI can ask for a
|
||||
* code. With a code, Stalwart 0.16+ no longer accepts the legacy
|
||||
* `password$totp` Basic convention, so the structured login endpoint is used
|
||||
* to obtain OAuth tokens (see `connectWithTotp`).
|
||||
*/
|
||||
async connect(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp?: string,
|
||||
): Promise<JMAPSession> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
if (totp) {
|
||||
return this.connectWithTotp(baseUrl, username, password, totp);
|
||||
}
|
||||
this.credentials = { serverUrl: baseUrl, username, password };
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
@@ -102,12 +244,41 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP login through Stalwart's structured auth endpoint (`/api/auth` with a
|
||||
* separate `mfaToken`, then `/auth/token` with PKCE). The resulting bundle is
|
||||
* persisted as an OAuth account so the normal refresh path keeps it alive.
|
||||
* Falls back to the legacy `password$totp` Basic convention when the server
|
||||
* predates the structured endpoint (404).
|
||||
*/
|
||||
private async connectWithTotp(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp: string,
|
||||
): Promise<JMAPSession> {
|
||||
const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login');
|
||||
let tokens: OAuthTokens;
|
||||
try {
|
||||
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp);
|
||||
} catch (err) {
|
||||
if (err instanceof LoginEndpointMissingError) {
|
||||
// Pre-0.16 Stalwart: TOTP rides along in the Basic password.
|
||||
return this.connect(baseUrl, username, `${password}$${totp}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { session } = await this.connectWithOAuth(baseUrl, tokens, username);
|
||||
return session;
|
||||
}
|
||||
|
||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
return this.session;
|
||||
}
|
||||
@@ -115,9 +286,12 @@ export class JMAPClient {
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
// Stalwart and handed us a complete token bundle. We persist the bundle
|
||||
// so subsequent launches can refresh without prompting the user again.
|
||||
// `preferredUsername` (the address the user typed) wins over the session's
|
||||
// `username`, which for OIDC logins may be a bare `preferred_username`.
|
||||
async connectWithOAuth(
|
||||
serverUrl: string,
|
||||
tokens: OAuthTokens,
|
||||
preferredUsername?: string,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = {
|
||||
@@ -129,15 +303,18 @@ export class JMAPClient {
|
||||
expiresAt: tokens.expiresAt,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
tokenSource: tokens.source,
|
||||
};
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
// The JMAP session document carries the authenticated user's identifier
|
||||
// — use it as the username so per-account storage keys are stable across
|
||||
// restarts and OAuth re-logins.
|
||||
const username = this.session.username || tokens.accessToken.slice(0, 8);
|
||||
const username =
|
||||
preferredUsername || this.session.username || tokens.accessToken.slice(0, 8);
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
@@ -151,6 +328,7 @@ export class JMAPClient {
|
||||
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
if (this.credentials.accessToken === next.accessToken) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
@@ -169,6 +347,9 @@ export class JMAPClient {
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
@@ -186,12 +367,29 @@ export class JMAPClient {
|
||||
expiresAt: this.credentials.expiresAt,
|
||||
tokenEndpoint: this.credentials.tokenEndpoint,
|
||||
clientId: this.credentials.clientId,
|
||||
source: this.credentials.tokenSource,
|
||||
};
|
||||
}
|
||||
|
||||
/** OAuth bundle stored for `accountId` (null for password accounts). */
|
||||
async getStoredOAuthTokens(accountId: string): Promise<OAuthTokens | null> {
|
||||
const creds = await this.getStoredCredentials(accountId);
|
||||
if (!creds?.accessToken || !creds.refreshToken || !creds.tokenEndpoint || !creds.clientId) return null;
|
||||
return {
|
||||
accessToken: creds.accessToken,
|
||||
refreshToken: creds.refreshToken,
|
||||
expiresAt: creds.expiresAt,
|
||||
tokenEndpoint: creds.tokenEndpoint,
|
||||
clientId: creds.clientId,
|
||||
source: creds.tokenSource,
|
||||
};
|
||||
}
|
||||
|
||||
// Proactive refresh: when the access token is about to expire, swap it for
|
||||
// a fresh one. Quiet no-op for password credentials.
|
||||
private async ensureFreshToken(): Promise<void> {
|
||||
// a fresh one. Quiet no-op for password credentials. Public so long-lived
|
||||
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
|
||||
// header they capture is not about to expire.
|
||||
async ensureFreshToken(): Promise<void> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
@@ -206,19 +404,43 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
// Reactive refresh after a 401. Returns true if a fresh token was obtained
|
||||
// so the caller can retry the original request.
|
||||
private async forceRefreshToken(): Promise<boolean> {
|
||||
// so the caller can retry the original request. A transient token-endpoint
|
||||
// failure (offline, 5xx, 429) is rethrown as a NetworkError so callers keep
|
||||
// the account instead of evicting it (webmail 1.7.6 "keep the session when
|
||||
// the auth server is briefly unreachable").
|
||||
async forceRefreshToken(): Promise<boolean> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return false;
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof TransientRefreshError) {
|
||||
throw new NetworkError(err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful password change the stored credential must follow,
|
||||
* otherwise the next request gets a 401 and the next launch evicts the
|
||||
* account. No-op for bearer sessions.
|
||||
*/
|
||||
async updatePassword(newPassword: string): Promise<void> {
|
||||
if (!this.credentials || this.credentials.accessToken) return;
|
||||
this.credentials = { ...this.credentials, password: newPassword };
|
||||
const accountId = generateAccountId(
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
@@ -233,6 +455,7 @@ export class JMAPClient {
|
||||
creds.serverUrl,
|
||||
);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
return true;
|
||||
} catch {
|
||||
await this.logout();
|
||||
@@ -256,6 +479,7 @@ export class JMAPClient {
|
||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||
// avoid clearing credentials when the server is just unreachable.
|
||||
this.credentials = creds;
|
||||
this.firstTouchGate.reset();
|
||||
try {
|
||||
this.session = this.rewriteSessionUrls(
|
||||
await this.fetchSession(creds.serverUrl),
|
||||
@@ -268,6 +492,7 @@ export class JMAPClient {
|
||||
this.session = null;
|
||||
this._accountId = null;
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
if (err instanceof NetworkError) throw err;
|
||||
// Anything else is treated as transport-level. Wrapping rather than
|
||||
// rethrowing the raw fetch error gives callers a single check.
|
||||
throw new NetworkError(
|
||||
@@ -278,6 +503,25 @@ export class JMAPClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the live connection so a failed "add account" / switch can put
|
||||
* the previous account back without a network round-trip.
|
||||
*/
|
||||
snapshot(): ClientSnapshot {
|
||||
return {
|
||||
session: this.session,
|
||||
credentials: this.credentials,
|
||||
accountId: this._accountId,
|
||||
};
|
||||
}
|
||||
|
||||
restoreSnapshot(snap: ClientSnapshot): void {
|
||||
this.session = snap.session;
|
||||
this.credentials = snap.credentials;
|
||||
this._accountId = snap.accountId;
|
||||
this.firstTouchGate.reset();
|
||||
}
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
||||
}
|
||||
@@ -310,20 +554,17 @@ export class JMAPClient {
|
||||
// Rewrite session URLs to share the origin the client connected with.
|
||||
// JMAP servers often self-report localhost/container-internal hostnames in
|
||||
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
|
||||
// the Android emulator can't resolve the host's "localhost").
|
||||
// the Android emulator can't resolve the host's "localhost"), and some
|
||||
// deployments advertise relative URLs (`/jmap/`) which `fetch` can't use.
|
||||
//
|
||||
// Splits via plain string indexing rather than `new URL()` because RN's
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = this.extractOrigin(serverUrl);
|
||||
const rewrite = (url: string | undefined): string | undefined => {
|
||||
if (!url) return url;
|
||||
const origin = this.extractOrigin(url);
|
||||
if (!origin || !serverOrigin || origin === serverOrigin) return url;
|
||||
return serverOrigin + url.slice(origin.length);
|
||||
};
|
||||
const serverOrigin = extractOrigin(serverUrl);
|
||||
const rewrite = (url: string | undefined): string | undefined =>
|
||||
rewriteSessionUrl(url, serverOrigin);
|
||||
return {
|
||||
...session,
|
||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||
@@ -333,17 +574,14 @@ export class JMAPClient {
|
||||
};
|
||||
}
|
||||
|
||||
private extractOrigin(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Reset in-memory state without touching persisted credentials (used on
|
||||
// account switch so the active account's stored creds remain available).
|
||||
reset(): void {
|
||||
this.session = null;
|
||||
this.credentials = null;
|
||||
this._accountId = null;
|
||||
this.firstTouchGate.reset();
|
||||
this.rateLimitedUntil = 0;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
@@ -357,31 +595,173 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transport ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `secureFetch` with a deadline on the response headers. The abort reason is
|
||||
* tracked separately so a caller-supplied signal (cancelled upload) can be
|
||||
* told apart from "we gave up".
|
||||
*/
|
||||
private async timedFetch(
|
||||
url: string,
|
||||
init: RequestInit | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const external = init?.signal ?? undefined;
|
||||
if (controller && external) {
|
||||
if (external.aborted) controller.abort();
|
||||
else external.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller?.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await secureFetch(url, { ...init, ...(controller ? { signal: controller.signal } : {}), timeoutMs });
|
||||
} catch (error) {
|
||||
if (timedOut) throw new RequestTimeoutError(timeoutMs);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch shared by the API, session and blob paths: proactive
|
||||
* token refresh, deadline, one transient retry for idempotent requests,
|
||||
* reactive refresh on 401, rate-limit bookkeeping on 429. Returns the
|
||||
* response for the caller to interpret; throws `NetworkError` /
|
||||
* `RequestTimeoutError` / `RateLimitError`.
|
||||
*/
|
||||
async authenticatedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
opts?: { timeoutMs?: number; idempotent?: boolean },
|
||||
): Promise<Response> {
|
||||
if (this.isRateLimited()) {
|
||||
throw new RateLimitError(this.rateLimitRemainingMs());
|
||||
}
|
||||
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
const idempotent = opts?.idempotent ?? true;
|
||||
|
||||
await this.ensureFreshToken();
|
||||
const withAuth = (): RequestInit => ({
|
||||
...init,
|
||||
headers: { ...(init?.headers as Record<string, string> | undefined), Authorization: this.authHeader },
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTimeoutError) throw error;
|
||||
if (!idempotent || (error as Error)?.name === 'AbortError') {
|
||||
throw toNetworkError(error);
|
||||
}
|
||||
// Transient proxy/connection blip: one retry after a short pause.
|
||||
await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS));
|
||||
try {
|
||||
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||
} catch (retryError) {
|
||||
if (retryError instanceof RequestTimeoutError) throw retryError;
|
||||
throw toNetworkError(retryError);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
try {
|
||||
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTimeoutError) throw error;
|
||||
throw toNetworkError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||||
this.setRateLimited(retryAfterMs);
|
||||
throw new RateLimitError(retryAfterMs);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── Session Discovery ─────────────────────────────────
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
// Stalwart's discovery endpoint /.well-known/jmap 307-redirects to
|
||||
// /jmap/session. On iOS, NSURLSession drops the Authorization header when
|
||||
// it auto-follows that redirect, so the app receives an unauthenticated,
|
||||
// empty-accounts session. Request the session endpoint directly so the
|
||||
// header stays attached; fall back to the standard well-known path for
|
||||
// servers that don't serve /jmap/session (#39).
|
||||
const primary = `${baseUrl}/jmap/session`;
|
||||
const fallback = `${baseUrl}/.well-known/jmap`;
|
||||
const fetchSessionDoc = async () => {
|
||||
const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } });
|
||||
return r.status === 404
|
||||
? this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } })
|
||||
: r;
|
||||
};
|
||||
let response = await fetchSessionDoc();
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Invalid credentials');
|
||||
}
|
||||
if (response.status === 402) {
|
||||
// Stalwart answers 402 with a problem+json whose title names the
|
||||
// missing factor ("TOTP code required" / "MFA code required").
|
||||
let title = '';
|
||||
try {
|
||||
const body = (await response.json()) as { title?: string; detail?: string };
|
||||
title = `${body?.title ?? ''} ${body?.detail ?? ''}`.toLowerCase();
|
||||
} catch {
|
||||
// fall through - treat any 402 as an MFA challenge
|
||||
}
|
||||
if (!title || title.includes('totp') || title.includes('mfa') || title.includes('factor')) {
|
||||
throw new TotpRequiredError();
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
let session = (await response.json()) as JMAPSession;
|
||||
|
||||
// Stalwart 307-redirects /.well-known/jmap to /jmap/session. iOS
|
||||
// NSURLSession (and some auth proxies) drop the Authorization header on
|
||||
// the redirect, which yields a 200 with an empty session. Refetch the
|
||||
// final URL with the header explicitly.
|
||||
const redirectedEmpty =
|
||||
(response as { redirected?: boolean }).redirected &&
|
||||
(!session?.accounts || Object.keys(session.accounts).length === 0) &&
|
||||
!session?.username;
|
||||
if (redirectedEmpty && (response as { url?: string }).url) {
|
||||
response = await this.authenticatedFetch((response as { url: string }).url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.status === 401) throw new AuthenticationError('Invalid credentials');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
session = (await response.json()) as JMAPSession;
|
||||
}
|
||||
|
||||
if (!fetchSessionDescribes(session)) {
|
||||
throw new Error('Session discovery failed: response is not a JMAP session');
|
||||
}
|
||||
// Stalwart answers 200 with an empty session (no accounts) for missing or
|
||||
// invalid credentials rather than a 401. Surface that as an auth failure
|
||||
// so the user sees "Invalid credentials" instead of "No account found".
|
||||
const hasAccount =
|
||||
Object.keys(session.primaryAccounts ?? {}).length > 0 ||
|
||||
Object.keys(session.accounts ?? {}).length > 0;
|
||||
if (!hasAccount) {
|
||||
throw new AuthenticationError('Invalid credentials');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private resolveAccountId(session: JMAPSession): string {
|
||||
@@ -399,6 +779,34 @@ export class JMAPClient {
|
||||
throw new Error('No account found in JMAP session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the session document (capabilities, shared accounts,
|
||||
* eventSourceUrl) without disturbing credentials. Used by the keep-alive
|
||||
* and after a basic-auth 401 that may just be a stale session.
|
||||
*/
|
||||
async refreshSession(): Promise<JMAPSession> {
|
||||
if (!this.credentials) throw new Error('No credentials');
|
||||
const baseUrl = this.credentials.serverUrl;
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap liveness probe (`Core/echo`). Resolves true when the server answered,
|
||||
* false on transport failure. Used by the foreground keep-alive.
|
||||
*/
|
||||
async ping(): Promise<boolean> {
|
||||
if (!this.session) return false;
|
||||
try {
|
||||
await this.request([['Core/echo', { ping: Date.now() }, 'ping']], [CAPABILITIES.CORE]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Request ───────────────────────────────────────
|
||||
|
||||
async request(
|
||||
@@ -411,38 +819,54 @@ export class JMAPClient {
|
||||
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
methodCalls,
|
||||
};
|
||||
const serialized = JSON.stringify(body);
|
||||
const apiUrl = this.session.apiUrl;
|
||||
const idempotent = !hasNonIdempotentMethod(methodCalls);
|
||||
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(this.session!.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await this.firstTouchGate.run(methodCalls, () =>
|
||||
this.authenticatedFetch(
|
||||
apiUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: serialized,
|
||||
},
|
||||
{ idempotent },
|
||||
),
|
||||
);
|
||||
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
if (response.status === 401) {
|
||||
const err = new AuthenticationError('Session expired');
|
||||
this.notifyAuthFailure(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const responseText = await readBodyText(response);
|
||||
|
||||
if (!response.ok) {
|
||||
// Stalwart caps parallel API requests per user (maxConcurrentRequests,
|
||||
// default 4). One push event fans out into several refreshes at once,
|
||||
// so the ceiling is reached in ordinary use. The refusal comes before
|
||||
// any method runs, so back off briefly and replay (#780).
|
||||
if (
|
||||
attempt < CONCURRENT_REQUEST_RETRY_DELAYS_MS.length &&
|
||||
isConcurrentRequestRefusal(response.status, responseText)
|
||||
) {
|
||||
const base = CONCURRENT_REQUEST_RETRY_DELAYS_MS[attempt];
|
||||
await new Promise((r) => setTimeout(r, base + Math.random() * base));
|
||||
continue;
|
||||
}
|
||||
const detail = responseText ? ` - ${responseText.substring(0, 200)}` : '';
|
||||
throw new Error(`JMAP request failed: ${response.status}${detail}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText) as JMAPResponseBody;
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from server');
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Session expired');
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const ms = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
|
||||
throw new RateLimitError(ms);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`JMAP request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ── Capability Check ──────────────────────────────────
|
||||
@@ -451,11 +875,93 @@ export class JMAPClient {
|
||||
return Boolean(this.session?.capabilities?.[urn]);
|
||||
}
|
||||
|
||||
getMaxObjectsInGet(): number {
|
||||
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
|
||||
| { maxObjectsInGet?: number }
|
||||
/**
|
||||
* True when `urn` is advertised for the account (RFC 8620 §2
|
||||
* `accountCapabilities`) or, as a fallback, at session level. Stalwart puts
|
||||
* its extension capabilities (`urn:stalwart:jmap`, filenode, …) only under
|
||||
* the account, so a session-level check alone is wrong for those.
|
||||
*/
|
||||
hasAccountCapability(urn: string, accountId?: string): boolean {
|
||||
const id = accountId ?? this._accountId;
|
||||
if (!id) return this.hasCapability(urn);
|
||||
const account = this.session?.accounts?.[id];
|
||||
if (account?.accountCapabilities && urn in account.accountCapabilities) return true;
|
||||
return this.hasCapability(urn);
|
||||
}
|
||||
|
||||
private get coreCapability():
|
||||
| { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number }
|
||||
| undefined {
|
||||
return this.session?.capabilities?.[CAPABILITIES.CORE] as
|
||||
| { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number }
|
||||
| undefined;
|
||||
return core?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
getMaxObjectsInGet(): number {
|
||||
return this.coreCapability?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
getMaxObjectsInSet(): number {
|
||||
return this.coreCapability?.maxObjectsInSet || 500;
|
||||
}
|
||||
|
||||
getMaxCallsInRequest(): number {
|
||||
return this.coreCapability?.maxCallsInRequest || 16;
|
||||
}
|
||||
|
||||
/** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */
|
||||
getMaxSizeUpload(): number {
|
||||
const max = this.coreCapability?.maxSizeUpload;
|
||||
return typeof max === 'number' && max > 0 ? max : 0;
|
||||
}
|
||||
|
||||
/** Per-message attachment total ceiling in bytes (0 = unknown). */
|
||||
getMaxSizeAttachmentsPerEmail(): number {
|
||||
const mail = this.session?.capabilities?.[CAPABILITIES.MAIL] as
|
||||
| { maxSizeAttachmentsPerEmail?: number }
|
||||
| undefined;
|
||||
const max = mail?.maxSizeAttachmentsPerEmail;
|
||||
return typeof max === 'number' && max > 0 ? max : 0;
|
||||
}
|
||||
|
||||
// ── Shared / group accounts ───────────────────────────
|
||||
// A JMAP session lists every account the credentials can reach: the user's
|
||||
// own, plus any shared/group accounts (Stalwart "group accounts") they are a
|
||||
// member of. Non-personal accounts are the shared mailboxes.
|
||||
|
||||
/**
|
||||
* Mail-capable accounts other than the primary one. Stalwart doesn't always
|
||||
* populate `accountCapabilities` on shared accounts, so an account counts as
|
||||
* mail-capable when it advertises the mail capability OR is non-personal —
|
||||
* the same rule the webmail's getSharedAccounts() applies.
|
||||
*/
|
||||
getSharedMailAccounts(): { id: string; name: string }[] {
|
||||
if (!this.session || !this._accountId) return [];
|
||||
const primaryId = this._accountId;
|
||||
const out: { id: string; name: string }[] = [];
|
||||
for (const [id, info] of Object.entries(this.session.accounts ?? {})) {
|
||||
if (id === primaryId) continue;
|
||||
const advertisesMail = info.accountCapabilities
|
||||
? CAPABILITIES.MAIL in info.accountCapabilities
|
||||
: false;
|
||||
if (!advertisesMail && info.isPersonal) continue;
|
||||
out.push({ id, name: info.name || id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
getAccountName(accountId: string): string | undefined {
|
||||
return this.session?.accounts?.[accountId]?.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary account for a capability (RFC 8620 `primaryAccounts`), falling
|
||||
* back to the mail primary. Contacts/calendars can live in a different
|
||||
* account than mail on some servers.
|
||||
*/
|
||||
getPrimaryAccountId(capability: string): string {
|
||||
const id = this.session?.primaryAccounts?.[capability];
|
||||
return id || this.accountId;
|
||||
}
|
||||
|
||||
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
||||
@@ -514,31 +1020,88 @@ export class JMAPClient {
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
// `accountId` targets a shared/group account, whose blobs aren't reachable
|
||||
// through the user's own account id.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
|
||||
if (!this.session?.downloadUrl) {
|
||||
throw new Error('Download URL not available - not connected');
|
||||
}
|
||||
return this.session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
.replace('{accountId}', encodeURIComponent(accountId ?? this.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name || 'download'))
|
||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||
}
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
async fetchBlobArrayBuffer(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
type?: string,
|
||||
accountId?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||
const response = await this.authenticatedFetch(url, undefined, {
|
||||
timeoutMs: BLOB_TIMEOUT_MS,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
const err = new AuthenticationError('Session expired');
|
||||
this.notifyAuthFailure(err);
|
||||
throw err;
|
||||
}
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientSnapshot {
|
||||
session: JMAPSession | null;
|
||||
credentials: StoredCredentials | null;
|
||||
accountId: string | null;
|
||||
}
|
||||
|
||||
// ── URL helpers ──────────────────────────────────────────
|
||||
|
||||
export function extractOrigin(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase a session URL onto the origin the client connected with. Relative
|
||||
* URLs (`/jmap/`) get the origin prefixed; absolute ones have their origin
|
||||
* swapped. Pure string work so `{accountId}`/`{blobId}` templates survive.
|
||||
*/
|
||||
export function rewriteSessionUrl(
|
||||
url: string | undefined,
|
||||
serverOrigin: string | null,
|
||||
): string | undefined {
|
||||
if (!url || !serverOrigin) return url;
|
||||
const origin = extractOrigin(url);
|
||||
if (!origin) {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // non-http scheme: leave it
|
||||
return serverOrigin + (url.startsWith('/') ? url : '/' + url);
|
||||
}
|
||||
if (origin === serverOrigin) return url;
|
||||
return serverOrigin + url.slice(origin.length);
|
||||
}
|
||||
|
||||
async function readBodyText(response: Response): Promise<string> {
|
||||
try {
|
||||
if (typeof response.text === 'function') return await response.text();
|
||||
if (typeof response.json === 'function') return JSON.stringify(await response.json());
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function toNetworkError(error: unknown): NetworkError {
|
||||
if (error instanceof NetworkError) return error;
|
||||
return new NetworkError(
|
||||
error instanceof Error && error.message ? error.message : 'Network request failed',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error Classes ─────────────────────────────────────────
|
||||
|
||||
export class AuthenticationError extends Error {
|
||||
@@ -548,6 +1111,14 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The server wants a second factor before it will hand out a session.
|
||||
export class TotpRequiredError extends AuthenticationError {
|
||||
constructor() {
|
||||
super('TOTP_REQUIRED');
|
||||
this.name = 'TotpRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
// Server unreachable / transient transport failure. Callers should keep
|
||||
// stored credentials and surface an offline state rather than logging out.
|
||||
export class NetworkError extends Error {
|
||||
@@ -557,6 +1128,15 @@ export class NetworkError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// No response headers within the deadline. The request may have reached the
|
||||
// server (a send may have gone out), so callers must not blindly retry.
|
||||
export class RequestTimeoutError extends Error {
|
||||
constructor(timeoutMs: number) {
|
||||
super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||
this.name = 'RequestTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Shared JMAP response helpers. Kept dependency-free (no client import) so
|
||||
// every `src/api/*.ts` module - and their tests, which mock `./jmap-client`
|
||||
// wholesale - can use them.
|
||||
//
|
||||
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
|
||||
// method-level `['error', {...}]` reply produced a TypeError on reads and was
|
||||
// silently treated as success on writes (the outbox then dropped the op).
|
||||
// Route every response through these instead.
|
||||
|
||||
import type { JMAPResponseBody } from './types';
|
||||
|
||||
type MethodResponse = [string, Record<string, any>, string];
|
||||
|
||||
export class JMAPMethodError extends Error {
|
||||
type: string;
|
||||
constructor(type: string, description?: string) {
|
||||
super(description || type || 'JMAP method error');
|
||||
this.name = 'JMAPMethodError';
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the response body for `callId` (or the first response when omitted)
|
||||
* and throw a `JMAPMethodError` when the server answered with the error
|
||||
* envelope. Also verifies the method name when `expectMethod` is given.
|
||||
*/
|
||||
export function requireMethodResult<T = Record<string, any>>(
|
||||
res: JMAPResponseBody,
|
||||
callId?: string,
|
||||
expectMethod?: string,
|
||||
): T {
|
||||
const responses = (res?.methodResponses ?? []) as MethodResponse[];
|
||||
const match = callId === undefined
|
||||
? responses[0]
|
||||
: responses.find((r) => r[2] === callId);
|
||||
if (!match) {
|
||||
throw new JMAPMethodError(
|
||||
'missingResponse',
|
||||
`Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`,
|
||||
);
|
||||
}
|
||||
if (match[0] === 'error') {
|
||||
const err = match[1] as { type?: string; description?: string };
|
||||
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
|
||||
}
|
||||
if (expectMethod && match[0] !== expectMethod) {
|
||||
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
|
||||
}
|
||||
return match[1] as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||||
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||||
*/
|
||||
export function assertSetResult(
|
||||
body: Record<string, any>,
|
||||
ids?: string[],
|
||||
what = 'object',
|
||||
): void {
|
||||
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
|
||||
for (const bucket of buckets) {
|
||||
const failures = body?.[bucket] as
|
||||
| Record<string, { type?: string; description?: string; properties?: string[] }>
|
||||
| undefined;
|
||||
if (!failures) continue;
|
||||
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
|
||||
if (entries.length === 0) continue;
|
||||
const [id, err] = entries[0];
|
||||
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
|
||||
const detail = [err?.type ?? 'unknown'];
|
||||
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err?.description) detail.push(err.description);
|
||||
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||||
throw new JMAPMethodError(
|
||||
err?.type ?? 'setError',
|
||||
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Split `items` into consecutive batches of at most `size` entries. */
|
||||
export function batched<T>(items: T[], size: number): T[][] {
|
||||
const step = Math.max(1, Math.floor(size));
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += step) {
|
||||
result.push(items.slice(i, i + step));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
|
||||
// Port of the webmail's JMAPClient.discoverKeywords (#658): page through the
|
||||
// account's messages newest-first (query + back-referenced Email/get of the
|
||||
// keywords only) and count every keyword that is set. Capped like the
|
||||
// offline sync so a noisy account can't be enumerated end to end.
|
||||
|
||||
export const DEFAULT_KEYWORD_SCAN_LIMIT = 5000;
|
||||
|
||||
export interface KeywordScanResult {
|
||||
/** keyword → number of scanned messages carrying it. */
|
||||
keywords: Record<string, number>;
|
||||
scanned: number;
|
||||
total: number;
|
||||
/** False when the cap stopped the scan before the end of the list. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export async function discoverKeywords(options?: {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: { aborted: boolean };
|
||||
accountId?: string;
|
||||
}): Promise<KeywordScanResult> {
|
||||
const accountId = options?.accountId ?? jmapClient.accountId;
|
||||
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
|
||||
const pageSize = Math.max(1, Math.min(500, jmapClient.getMaxObjectsInGet()));
|
||||
const keywords: Record<string, number> = {};
|
||||
let scanned = 0;
|
||||
let total = 0;
|
||||
let complete = false;
|
||||
|
||||
while (scanned < cap) {
|
||||
if (options?.signal?.aborted) break;
|
||||
const limit = Math.min(pageSize, cap - scanned);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
position: scanned,
|
||||
calculateTotal: scanned === 0,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['keywords'],
|
||||
}, '1'],
|
||||
]);
|
||||
const query = res.methodResponses?.[0]?.[1] ?? {};
|
||||
const got = res.methodResponses?.[1]?.[1] ?? {};
|
||||
const ids: string[] = (query.ids as string[] | undefined) ?? [];
|
||||
if (scanned === 0) total = (query.total as number | undefined) ?? 0;
|
||||
|
||||
for (const email of (got.list as Array<{ keywords?: Record<string, boolean> }> | undefined) ?? []) {
|
||||
for (const [keyword, isSet] of Object.entries(email.keywords ?? {})) {
|
||||
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Page by what the query returned, not by what the get did: a message
|
||||
// destroyed between the two lands in `notFound` and would otherwise
|
||||
// shift every later page by one.
|
||||
scanned += ids.length;
|
||||
options?.onProgress?.(scanned, Math.max(total, scanned));
|
||||
|
||||
// A short page is the end of the list, however much `total` claimed.
|
||||
if (ids.length === 0 || ids.length < limit) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { keywords, scanned, total: Math.max(total, scanned), complete };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Naming one member of an object in a JMAP PatchObject. Port of the webmail's
|
||||
* lib/jmap/patch-pointer.ts.
|
||||
*
|
||||
* A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash in the
|
||||
* member's own name starts a new path segment unless it is escaped. That bites
|
||||
* keywords: a nested tag is stored as `$label:work/clients`, and the unescaped
|
||||
* pointer `keywords/$label:work/clients` asks for the `clients` member of the
|
||||
* `$label:work` keyword - not a boolean, so the patch is rejected or ignored
|
||||
* and the tag never lands.
|
||||
*/
|
||||
|
||||
/** One JSON Pointer reference token: `~` -> `~0`, `/` -> `~1` (RFC 6901). */
|
||||
export function pointerToken(value: string): string {
|
||||
return value.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The member name a reference token stands for: the inverse of
|
||||
* `pointerToken`. `~1` is read before `~0`, per RFC 6901 - the other order
|
||||
* would turn the escaped `~1` of `~01` back into a slash.
|
||||
*/
|
||||
export function pointerTokenValue(token: string): string {
|
||||
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
|
||||
/** The PatchObject key that sets or clears the keyword `keyword`. */
|
||||
export function keywordPointer(keyword: string): string {
|
||||
return `keywords/${pointerToken(keyword)}`;
|
||||
}
|
||||
|
||||
/** The PatchObject key that adds or removes membership of `mailboxId`. */
|
||||
export function mailboxPointer(mailboxId: string): string {
|
||||
return `mailboxIds/${pointerToken(mailboxId)}`;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// Live updates from the JMAP EventSource (SSE), with the lifecycle the plain
|
||||
// `react-native-sse` EventSource lacks:
|
||||
//
|
||||
// - reconnect with back-off after a network error (the library only re-polls
|
||||
// when the XHR reaches DONE with a status; a dropped socket - iOS
|
||||
// suspension, Wi-Fi → cellular - fires `error` with status 0 and then
|
||||
// nothing ever happens again);
|
||||
// - a fresh Authorization header on every (re)connect, and an immediate
|
||||
// reconnect when the OAuth token rotates (the library reuses the headers it
|
||||
// was constructed with, so after a refresh every retry sent the stale
|
||||
// bearer: 401 → error → retry every 5 s forever);
|
||||
// - a 90 s ping watchdog and a periodic recycle so the accumulated
|
||||
// `responseText` cannot grow without bound;
|
||||
// - fallback to polling when the stream is refused (proxy without SSE, TLS
|
||||
// client certificate that XHR cannot present, repeated failures).
|
||||
|
||||
import { jmapClient, AuthenticationError } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { StateChange } from './types';
|
||||
import { getClientCertAlias } from '../lib/client-cert';
|
||||
|
||||
export type StateChangeHandler = (change: StateChange) => void;
|
||||
|
||||
// Server pings every 30 s; consider the stream dead after three missed ones.
|
||||
const PING_SECONDS = 30;
|
||||
const PING_TIMEOUT_MS = 90_000;
|
||||
// Recycle a healthy stream periodically to bound the XHR buffer.
|
||||
const RECYCLE_MS = 30 * 60_000;
|
||||
const RECONNECT_DELAYS_MS = [2_000, 5_000, 10_000, 20_000, 40_000, 60_000];
|
||||
// After this many consecutive failed connects the stream is given up on and
|
||||
// polling takes over for the rest of the session.
|
||||
const MAX_CONSECUTIVE_FAILURES = 6;
|
||||
|
||||
export const POLL_INTERVAL_MS = 5_000;
|
||||
export const SLOW_POLL_INTERVAL_MS = 20_000;
|
||||
|
||||
interface SseEvent {
|
||||
type?: string;
|
||||
data?: string;
|
||||
message?: string;
|
||||
xhrStatus?: number;
|
||||
}
|
||||
|
||||
interface EventSourceLike {
|
||||
addEventListener: (type: string, handler: (event: SseEvent) => void) => void;
|
||||
removeEventListener: (type: string, handler: (event: SseEvent) => void) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
type EventSourceCtor = new (
|
||||
url: string,
|
||||
options: { headers: Record<string, string>; pollingInterval?: number; timeoutBeforeConnection?: number },
|
||||
) => EventSourceLike;
|
||||
|
||||
function eventSourceImpl(): EventSourceCtor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('react-native-sse').default as EventSourceCtor;
|
||||
}
|
||||
|
||||
export interface LiveUpdatesOptions {
|
||||
onStateChange: StateChangeHandler;
|
||||
onError?: (error: Error) => void;
|
||||
/** Called when the stream is abandoned for polling. */
|
||||
onFallback?: (reason: string) => void;
|
||||
/** Returns false while the app is backgrounded; polling pauses. */
|
||||
isActive?: () => boolean;
|
||||
}
|
||||
|
||||
export interface LiveUpdatesHandle {
|
||||
/** Tear everything down (stream, timers, polling). */
|
||||
close: () => void;
|
||||
/** Drop the current stream and connect again (foreground resume). */
|
||||
reconnect: () => void;
|
||||
/** 'sse' | 'polling' | 'closed' */
|
||||
readonly mode: 'sse' | 'polling' | 'closed';
|
||||
}
|
||||
|
||||
function buildEventSourceUrl(template: string): string {
|
||||
return template
|
||||
.replace('{types}', '*')
|
||||
.replace('{closeafter}', 'no')
|
||||
.replace('{ping}', String(PING_SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling fallback: `Mailbox/get` + `Email/get` with empty id lists return
|
||||
* the current state tokens; a change in either is reported as a StateChange
|
||||
* for the primary account. One request in flight at a time, paused while
|
||||
* backgrounded.
|
||||
*/
|
||||
export function startPolling(
|
||||
onStateChange: StateChangeHandler,
|
||||
interval = POLL_INTERVAL_MS,
|
||||
isActive: () => boolean = () => true,
|
||||
): () => void {
|
||||
let accountId: string;
|
||||
try {
|
||||
accountId = jmapClient.accountId;
|
||||
} catch {
|
||||
return () => undefined;
|
||||
}
|
||||
const stateCache: Record<string, string> = {};
|
||||
let inFlight = false;
|
||||
let stopped = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped || inFlight || !isActive()) return;
|
||||
if ('isConnected' in jmapClient && !jmapClient.isConnected) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Mailbox/get', { accountId, ids: [] }, 'm'],
|
||||
['Email/get', { accountId, ids: [] }, 'e'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
|
||||
const changed: Record<string, string> = {};
|
||||
for (const [method, result] of res.methodResponses) {
|
||||
if (method === 'error') continue;
|
||||
const type = method.replace('/get', '');
|
||||
const newState = (result as { state?: string }).state;
|
||||
if (newState && stateCache[type] && stateCache[type] !== newState) {
|
||||
changed[type] = newState;
|
||||
}
|
||||
if (newState) stateCache[type] = newState;
|
||||
}
|
||||
|
||||
if (Object.keys(changed).length > 0) {
|
||||
onStateChange({ '@type': 'StateChange', changed: { [accountId]: changed } });
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next interval
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(() => { void tick(); }, interval);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start live updates: SSE when the session advertises an eventSourceUrl and
|
||||
* no client certificate is in play, polling otherwise. Resolves to a handle
|
||||
* that can be closed or told to reconnect.
|
||||
*/
|
||||
export async function startLiveUpdates(opts: LiveUpdatesOptions): Promise<LiveUpdatesHandle> {
|
||||
const isActive = opts.isActive ?? (() => true);
|
||||
const safeOnStateChange: StateChangeHandler = (change) => {
|
||||
try {
|
||||
opts.onStateChange(change);
|
||||
} catch (err) {
|
||||
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
let mode: 'sse' | 'polling' | 'closed' = 'closed';
|
||||
let stopPolling: (() => void) | null = null;
|
||||
let es: EventSourceLike | null = null;
|
||||
let esHandlers: Array<[string, (event: SseEvent) => void]> = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
||||
let recycleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let consecutiveFailures = 0;
|
||||
let closed = false;
|
||||
let unsubscribeTokenRefresh: (() => void) | null = null;
|
||||
|
||||
const session = jmapClient.currentSession;
|
||||
const template = session?.eventSourceUrl;
|
||||
const certAlias = await getClientCertAlias().catch(() => null);
|
||||
|
||||
const clearTimers = () => {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
|
||||
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
|
||||
};
|
||||
|
||||
const dropStream = () => {
|
||||
if (es) {
|
||||
for (const [type, handler] of esHandlers) {
|
||||
try { es.removeEventListener(type, handler); } catch { /* ignore */ }
|
||||
}
|
||||
esHandlers = [];
|
||||
try { es.close(); } catch { /* ignore */ }
|
||||
es = null;
|
||||
}
|
||||
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
|
||||
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
|
||||
};
|
||||
|
||||
const fallbackToPolling = (reason: string) => {
|
||||
if (closed) return;
|
||||
dropStream();
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (mode !== 'polling') {
|
||||
mode = 'polling';
|
||||
opts.onFallback?.(reason);
|
||||
stopPolling = startPolling(safeOnStateChange, SLOW_POLL_INTERVAL_MS, isActive);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closed || reconnectTimer) return;
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
fallbackToPolling('stream failed repeatedly');
|
||||
return;
|
||||
}
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(consecutiveFailures, RECONNECT_DELAYS_MS.length - 1)];
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const armWatchdog = () => {
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
watchdog = setTimeout(() => {
|
||||
// No ping for 90 s: the socket is dead even though the XHR never said so.
|
||||
dropStream();
|
||||
consecutiveFailures += 1;
|
||||
scheduleReconnect();
|
||||
}, PING_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
if (closed || !template) return;
|
||||
if (!isActive()) return; // resumed later by reconnect()
|
||||
dropStream();
|
||||
try {
|
||||
await jmapClient.ensureFreshToken();
|
||||
} catch {
|
||||
// the 401 path below handles a stale token
|
||||
}
|
||||
let authHeader: string;
|
||||
try {
|
||||
authHeader = jmapClient.authHeader;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const url = buildEventSourceUrl(template);
|
||||
let source: EventSourceLike;
|
||||
try {
|
||||
const Impl = eventSourceImpl();
|
||||
// pollingInterval 0 disables the library's own re-poll on DONE; every
|
||||
// reconnect goes through scheduleReconnect so headers stay fresh.
|
||||
source = new Impl(url, {
|
||||
headers: { Authorization: authHeader },
|
||||
pollingInterval: 0,
|
||||
timeoutBeforeConnection: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
consecutiveFailures += 1;
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
es = source;
|
||||
mode = 'sse';
|
||||
|
||||
const on = (type: string, handler: (event: SseEvent) => void) => {
|
||||
source.addEventListener(type, handler);
|
||||
esHandlers.push([type, handler]);
|
||||
};
|
||||
|
||||
on('open', () => {
|
||||
consecutiveFailures = 0;
|
||||
armWatchdog();
|
||||
if (recycleTimer) clearTimeout(recycleTimer);
|
||||
recycleTimer = setTimeout(() => {
|
||||
if (closed || es !== source) return;
|
||||
dropStream();
|
||||
void connect();
|
||||
}, RECYCLE_MS);
|
||||
});
|
||||
on('ping', () => armWatchdog());
|
||||
on('state', (event) => {
|
||||
armWatchdog();
|
||||
try {
|
||||
const data: StateChange = JSON.parse(typeof event === 'string' ? event : event.data ?? '');
|
||||
safeOnStateChange(data);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
on('close', () => {
|
||||
if (closed || es !== source) return;
|
||||
dropStream();
|
||||
scheduleReconnect();
|
||||
});
|
||||
on('error', (event) => {
|
||||
if (closed || es !== source) return;
|
||||
dropStream();
|
||||
const status = event?.xhrStatus ?? 0;
|
||||
if (status === 401) {
|
||||
// Stale bearer: refresh and come straight back with the new header.
|
||||
void jmapClient.forceRefreshToken().then(
|
||||
(refreshed) => {
|
||||
if (closed) return;
|
||||
if (refreshed) {
|
||||
consecutiveFailures = 0;
|
||||
void connect();
|
||||
} else {
|
||||
consecutiveFailures += 1;
|
||||
opts.onError?.(new AuthenticationError('Live updates rejected: session expired'));
|
||||
scheduleReconnect();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
consecutiveFailures += 1;
|
||||
scheduleReconnect();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status >= 400) {
|
||||
// The endpoint answered but refused the stream (proxy without SSE,
|
||||
// rate limit, server error): don't hammer it.
|
||||
consecutiveFailures += 2;
|
||||
opts.onError?.(new Error(`Live updates stream refused: ${status}`));
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
// status 0: socket dropped / no network. Back off and retry.
|
||||
consecutiveFailures += 1;
|
||||
scheduleReconnect();
|
||||
});
|
||||
};
|
||||
|
||||
if (!template || certAlias) {
|
||||
mode = 'polling';
|
||||
opts.onFallback?.(certAlias ? 'client certificate in use' : 'server has no eventSourceUrl');
|
||||
stopPolling = startPolling(safeOnStateChange, POLL_INTERVAL_MS, isActive);
|
||||
} else {
|
||||
unsubscribeTokenRefresh = jmapClient.onTokenRefresh(() => {
|
||||
if (closed || mode !== 'sse') return;
|
||||
// New bearer: the open stream still carries the old one for its
|
||||
// lifetime, which is fine, but every reconnect from now on must use
|
||||
// the new header - recycle now so a pending stale retry can't loop.
|
||||
dropStream();
|
||||
consecutiveFailures = 0;
|
||||
void connect();
|
||||
});
|
||||
await connect();
|
||||
}
|
||||
|
||||
return {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
mode = 'closed';
|
||||
clearTimers();
|
||||
dropStream();
|
||||
unsubscribeTokenRefresh?.();
|
||||
unsubscribeTokenRefresh = null;
|
||||
stopPolling?.();
|
||||
stopPolling = null;
|
||||
},
|
||||
reconnect: () => {
|
||||
if (closed) return;
|
||||
if (mode === 'polling') return; // polling keeps running on its own
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
consecutiveFailures = 0;
|
||||
dropStream();
|
||||
void connect();
|
||||
},
|
||||
};
|
||||
}
|
||||
+27
-106
@@ -1,6 +1,6 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { PushSubscription, StateChange } from './types';
|
||||
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
|
||||
|
||||
export type StateChangeHandler = (change: StateChange) => void;
|
||||
|
||||
@@ -27,6 +27,9 @@ export async function createPushSubscription(params: {
|
||||
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
||||
// want and accept whatever Stalwart returns.
|
||||
expires?: string;
|
||||
// draft-ietf-jmap-emailpush delivery filter, only when the server advertises
|
||||
// urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush).
|
||||
emailPush?: Record<string, EmailPushConfig>;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
@@ -34,6 +37,7 @@ export async function createPushSubscription(params: {
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
if (params.emailPush) created.emailPush = params.emailPush;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
@@ -63,7 +67,7 @@ export async function createPushSubscription(params: {
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
|
||||
): Promise<boolean> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
@@ -113,123 +117,40 @@ export async function destroyPushSubscription(id: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
|
||||
* Returns a cleanup function to close the connection.
|
||||
*/
|
||||
export function connectEventSource(
|
||||
onStateChange: StateChangeHandler,
|
||||
): (() => void) | null {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session?.eventSourceUrl) return null;
|
||||
// ─── Live updates ────────────────────────────────────────
|
||||
// The EventSource lifecycle (reconnect with back-off, fresh bearer on every
|
||||
// connect, ping watchdog, polling fallback) lives in ./push-stream. These
|
||||
// re-exports keep the historical entry points.
|
||||
|
||||
const url = session.eventSourceUrl
|
||||
.replace('{types}', '*')
|
||||
.replace('{closeafter}', 'no')
|
||||
.replace('{ping}', '30');
|
||||
|
||||
// Use native EventSource or react-native-sse
|
||||
const EventSourceImpl = typeof EventSource !== 'undefined'
|
||||
? EventSource
|
||||
: require('react-native-sse').default;
|
||||
|
||||
const es = new EventSourceImpl(url, {
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
|
||||
const handler = (event: any) => {
|
||||
try {
|
||||
const data: StateChange = JSON.parse(
|
||||
typeof event === 'string' ? event : event.data,
|
||||
);
|
||||
onStateChange(data);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('state', handler);
|
||||
|
||||
return () => {
|
||||
es.removeEventListener('state', handler);
|
||||
es.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling fallback when SSE is not available.
|
||||
* Returns a cleanup function to stop polling.
|
||||
*/
|
||||
export function startPolling(
|
||||
onStateChange: StateChangeHandler,
|
||||
interval = 5000,
|
||||
): () => void {
|
||||
const accountId = jmapClient.accountId;
|
||||
const stateCache: Record<string, string> = {};
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Mailbox/get', { accountId, ids: [] }, 'm'],
|
||||
['Email/get', { accountId, ids: [] }, 'e'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
|
||||
const changed: Record<string, string> = {};
|
||||
for (const [method, result] of res.methodResponses) {
|
||||
const type = method.replace('/get', '');
|
||||
const newState = result.state;
|
||||
if (newState && stateCache[type] && stateCache[type] !== newState) {
|
||||
changed[type] = newState;
|
||||
}
|
||||
if (newState) stateCache[type] = newState;
|
||||
}
|
||||
|
||||
if (Object.keys(changed).length > 0) {
|
||||
onStateChange({
|
||||
'@type': 'StateChange',
|
||||
changed: { [accountId]: changed },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next interval
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
export {
|
||||
startLiveUpdates,
|
||||
startPolling,
|
||||
type LiveUpdatesHandle,
|
||||
type LiveUpdatesOptions,
|
||||
} from './push-stream';
|
||||
|
||||
export interface StartPushOptions {
|
||||
onStateChange: StateChangeHandler;
|
||||
onError?: (error: Error) => void;
|
||||
onFallback?: (reason: string) => void;
|
||||
isActive?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start real-time updates, preferring SSE with polling fallback. The `_client`
|
||||
* argument is unused today (the underlying event source uses the singleton
|
||||
* `jmapClient`) but is part of the call signature so the host App can pass
|
||||
* the active client reference and trigger React re-runs on account switch.
|
||||
* Start real-time updates, preferring SSE with polling fallback. Resolves to
|
||||
* a cleanup function; prefer `startLiveUpdates` when the handle's
|
||||
* `reconnect()` is needed (foreground resume).
|
||||
*/
|
||||
export function startPushUpdates(
|
||||
export async function startPushUpdates(
|
||||
_client: unknown,
|
||||
opts: StartPushOptions,
|
||||
): () => void {
|
||||
const onError = opts.onError;
|
||||
const safeOnStateChange: StateChangeHandler = (change) => {
|
||||
try {
|
||||
opts.onStateChange(change);
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
};
|
||||
): Promise<() => void> {
|
||||
const { startLiveUpdates: start } = await import('./push-stream');
|
||||
try {
|
||||
const cleanup = connectEventSource(safeOnStateChange);
|
||||
if (cleanup) return cleanup;
|
||||
return startPolling(safeOnStateChange);
|
||||
const handle = await start(opts);
|
||||
return () => handle.close();
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
|
||||
// RFC 9425 Quota object (Stalwart reports storage as resourceType "octets"
|
||||
// with scope "account"); older servers used a pre-RFC "mail" shape.
|
||||
interface JMAPQuota {
|
||||
id: string;
|
||||
resourceType?: string;
|
||||
used?: number;
|
||||
hardLimit?: number;
|
||||
warnLimit?: number;
|
||||
softLimit?: number;
|
||||
// pre-RFC shape
|
||||
limit?: number;
|
||||
scope?: string;
|
||||
types?: string[];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface MailQuota {
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function serverSupportsQuota(): boolean {
|
||||
return Boolean(jmapClient.currentSession?.capabilities?.[CAPABILITIES.QUOTA]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage usage for the primary account, or null when the server does not
|
||||
* advertise urn:ietf:params:jmap:quota or reports no mail storage quota.
|
||||
* Mirrors the webmail's client.getQuota().
|
||||
*/
|
||||
export async function fetchMailQuota(): Promise<MailQuota | null> {
|
||||
if (!serverSupportsQuota()) return null;
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[['Quota/get', { accountId: jmapClient.accountId }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.QUOTA],
|
||||
);
|
||||
const [name, body] = res.methodResponses[0] ?? [];
|
||||
if (name !== 'Quota/get') return null;
|
||||
const quotas = (body?.list ?? []) as JMAPQuota[];
|
||||
const coversMail = (q: JMAPQuota) =>
|
||||
!q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail');
|
||||
const mailQuota =
|
||||
quotas.find((q) => q.resourceType === 'octets' && coversMail(q))
|
||||
|| quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail');
|
||||
if (!mailQuota) return null;
|
||||
const total = mailQuota.hardLimit ?? mailQuota.limit ?? 0;
|
||||
return { used: mailQuota.used ?? 0, total };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { EmailAddress } from './types';
|
||||
|
||||
export interface RecentRecipient {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
|
||||
function collect(
|
||||
emails: Array<{ to?: EmailAddress[] | null; cc?: EmailAddress[] | null }>,
|
||||
keep: (key: string, name: string) => boolean = () => true,
|
||||
): RecentRecipient[] {
|
||||
// Messages arrive newest-first, so the first occurrence per address is the
|
||||
// most recent one and carries the freshest display name.
|
||||
const byEmail = new Map<string, RecentRecipient>();
|
||||
for (const email of emails) {
|
||||
for (const r of [...(email.to || []), ...(email.cc || [])]) {
|
||||
if (!r?.email) continue;
|
||||
const key = r.email.toLowerCase().trim();
|
||||
if (!key || byEmail.has(key)) continue;
|
||||
const name = (r.name || '').trim();
|
||||
if (!keep(key, name)) continue;
|
||||
byEmail.set(key, { name, email: r.email });
|
||||
}
|
||||
}
|
||||
return Array.from(byEmail.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* The people the user has written to: scans the newest `limit` messages of the
|
||||
* Sent mailbox and returns each To/Cc address once (OWA-style autocomplete
|
||||
* cache). Only the recipient headers are fetched - no subject, preview or body.
|
||||
*/
|
||||
export async function queryRecentRecipients(
|
||||
sentMailboxId: string,
|
||||
limit = 300,
|
||||
accountId?: string,
|
||||
): Promise<RecentRecipient[]> {
|
||||
if (!sentMailboxId) return [];
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId: account,
|
||||
filter: { inMailbox: sentMailboxId },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId: account,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['to', 'cc'],
|
||||
}, '1'],
|
||||
], USING);
|
||||
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
|
||||
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
|
||||
to?: EmailAddress[] | null;
|
||||
cc?: EmailAddress[] | null;
|
||||
}>;
|
||||
return collect(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand server search of the Sent mailbox for recipients matching `query`
|
||||
* (used when the local caches have no hit). Keeps only the addresses that
|
||||
* actually match, not every co-recipient of a matching message.
|
||||
*/
|
||||
export async function searchSentRecipients(
|
||||
query: string,
|
||||
sentMailboxId: string,
|
||||
limit = 60,
|
||||
accountId?: string,
|
||||
): Promise<RecentRecipient[]> {
|
||||
const q = query.trim();
|
||||
if (!q || !sentMailboxId) return [];
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId: account,
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: sentMailboxId },
|
||||
{ operator: 'OR', conditions: [{ to: q }, { cc: q }] },
|
||||
],
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId: account,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['to', 'cc'],
|
||||
}, '1'],
|
||||
], USING);
|
||||
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
|
||||
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
|
||||
to?: EmailAddress[] | null;
|
||||
cc?: EmailAddress[] | null;
|
||||
}>;
|
||||
const lower = q.toLowerCase();
|
||||
return collect(list, (key, name) => key.includes(lower) || name.toLowerCase().includes(lower));
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { EmailAddress } from './types';
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
to: EmailAddress[];
|
||||
cc?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
subject: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string;
|
||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
},
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: email.from,
|
||||
to: email.to,
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
mailboxIds: { [sentMailboxId]: true },
|
||||
keywords: { $seen: true },
|
||||
};
|
||||
|
||||
if (email.htmlBody) {
|
||||
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
||||
emailCreate.bodyValues = { html: { value: email.htmlBody } };
|
||||
} else {
|
||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
|
||||
}
|
||||
|
||||
if (email.attachments?.length) {
|
||||
emailCreate.attachments = email.attachments.map((a) => ({
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: 'attachment',
|
||||
}));
|
||||
}
|
||||
|
||||
if (email.inReplyTo) {
|
||||
emailCreate['header:In-Reply-To:asText'] = email.inReplyTo;
|
||||
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
|
||||
}
|
||||
|
||||
await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: { 'sub-1': { emailId: '#draft', identityId } },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import type { JMAPMethodCall } from './types';
|
||||
|
||||
export interface TagCount {
|
||||
/** Tag id (the part after `$label:`). */
|
||||
id: string;
|
||||
total: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread/total counts per tag across every folder of one account — two
|
||||
* `Email/query` calls (`calculateTotal`, `limit: 0`) per tag, batched to the
|
||||
* server's maxCallsInRequest. Port of the webmail's `fetchTagCounts`
|
||||
* (stores/email-store.ts). A tag whose queries fail is reported as 0/0
|
||||
* rather than sinking the whole batch.
|
||||
*/
|
||||
export async function fetchTagCounts(tagIds: string[], accountIdOverride?: string): Promise<TagCount[]> {
|
||||
if (tagIds.length === 0) return [];
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
||||
|
||||
const calls: Array<{ id: string; kind: 'total' | 'unread'; call: JMAPMethodCall }> = [];
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'total',
|
||||
call: ['Email/query', { accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true }, `t:${id}`],
|
||||
});
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'unread',
|
||||
call: ['Email/query', {
|
||||
accountId,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `u:${id}`],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < calls.length; i += perRequest) {
|
||||
const chunk = calls.slice(i, i + perRequest);
|
||||
const res = await jmapClient.request(chunk.map((c) => c.call));
|
||||
for (const [name, body, callId] of res.methodResponses) {
|
||||
if (name !== 'Email/query' || typeof callId !== 'string') continue;
|
||||
const [kind, ...rest] = callId.split(':');
|
||||
const id = rest.join(':');
|
||||
const entry = counts.get(id);
|
||||
if (!entry) continue;
|
||||
const total = typeof body.total === 'number' ? body.total : 0;
|
||||
if (kind === 't') entry.total = total;
|
||||
else if (kind === 'u') entry.unread = total;
|
||||
}
|
||||
}
|
||||
return tagIds.map((id) => counts.get(id)!);
|
||||
}
|
||||
+89
-17
@@ -78,6 +78,14 @@ export interface Email {
|
||||
subject?: string;
|
||||
preview?: string;
|
||||
hasAttachment: boolean;
|
||||
// RFC 8621 §4.1.2.3 - bare msg-ids without angle brackets. Needed for
|
||||
// reply threading (In-Reply-To / References) and read-receipt dedupe.
|
||||
messageId?: string[] | null;
|
||||
inReplyTo?: string[] | null;
|
||||
references?: string[] | null;
|
||||
// Raw headers (RFC 8621 §4.1.3) when fetched with `headers`; the viewer
|
||||
// derives SPF/DKIM/DMARC chips, List-Unsubscribe, MDN requests from them.
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
textBody?: BodyPart[];
|
||||
htmlBody?: BodyPart[];
|
||||
bodyValues?: Record<string, { value: string; isEncodingProblem?: boolean }>;
|
||||
@@ -99,7 +107,15 @@ export interface MailboxRights {
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
/**
|
||||
* Unique within the app. For the user's own folders this is the raw JMAP id;
|
||||
* for a shared (group) account's folders it's `<accountId>:<rawId>` so ids
|
||||
* from different accounts can't collide. Use `originalId` when talking to
|
||||
* the server. Mirrors the webmail's scheme in [lib/jmap/client.ts].
|
||||
*/
|
||||
id: string;
|
||||
/** Raw JMAP id — set only on shared folders, where `id` carries a prefix. */
|
||||
originalId?: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
role?: string | null;
|
||||
@@ -110,6 +126,12 @@ export interface Mailbox {
|
||||
unreadThreads: number;
|
||||
myRights: MailboxRights;
|
||||
isSubscribed?: boolean;
|
||||
/** JMAP account the folder lives under (primary or a shared/group account). */
|
||||
accountId?: string;
|
||||
/** Display name of the owning account, used as the sidebar group header. */
|
||||
accountName?: string;
|
||||
/** True when the folder belongs to a shared/group account, not the user. */
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
@@ -343,6 +365,12 @@ export interface ContactCard {
|
||||
prodId?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
// Client-only metadata for cards that live in a shared / group account.
|
||||
// Never sent to the server (api/contacts.ts strips them). `id` is
|
||||
// namespaced as `<accountId>:<originalId>` for shared cards.
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface AddressBookRights {
|
||||
@@ -360,6 +388,15 @@ export interface AddressBook {
|
||||
isSubscribed?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: AddressBookRights;
|
||||
// RFC 9610 share map: principal id → rights.
|
||||
shareWith?: Record<string, AddressBookRights> | null;
|
||||
// Client-only metadata for books that live in a shared / group account.
|
||||
// `id` is namespaced as `<accountId>:<originalId>` for shared books so it
|
||||
// cannot collide with the user's own "default" book.
|
||||
originalId?: string;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface AddressBookWithCount extends AddressBook {
|
||||
@@ -379,6 +416,8 @@ export interface Participant {
|
||||
roles?: Record<string, boolean>;
|
||||
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
||||
participationComment?: string;
|
||||
// 'server' asks Stalwart to deliver the iTIP messages for this participant.
|
||||
scheduleAgent?: 'server' | 'client' | 'none' | string;
|
||||
scheduleStatus?: string[];
|
||||
expectReply?: boolean;
|
||||
description?: string;
|
||||
@@ -425,8 +464,11 @@ export interface RecurrenceRule {
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
trigger: { '@type': string; offset?: string; when?: string };
|
||||
'@type'?: 'Alert';
|
||||
trigger: { '@type': string; offset?: string; when?: string; relativeTo?: 'start' | 'end' | string };
|
||||
action?: string;
|
||||
// UTCDateTime the user acknowledged the alert; null/absent when pending.
|
||||
acknowledged?: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
@@ -438,33 +480,40 @@ export interface CalendarEvent {
|
||||
description?: string;
|
||||
start: string;
|
||||
duration?: string;
|
||||
timeZone?: string;
|
||||
// `null` on write clears a field the server holds (JSON drops `undefined`,
|
||||
// so an omitted key keeps the old value). All-day events carry a null zone.
|
||||
timeZone?: string | null;
|
||||
showWithoutTime?: boolean;
|
||||
utcStart?: string;
|
||||
utcEnd?: string;
|
||||
status?: string;
|
||||
freeBusyStatus?: string;
|
||||
participants?: Record<string, Participant>;
|
||||
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
|
||||
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
|
||||
replyTo?: Record<string, string>;
|
||||
organizerCalendarAddress?: string;
|
||||
participants?: Record<string, Participant> | null;
|
||||
// Scheduling (iTIP/iMIP). Stalwart derives the iCalendar ORGANIZER solely
|
||||
// from `organizerCalendarAddress` (a `mailto:` URI); RFC 8984's `replyTo` is
|
||||
// retired in jscalendarbis and only kept here so old values round-trip.
|
||||
replyTo?: Record<string, string> | null;
|
||||
organizerCalendarAddress?: string | null;
|
||||
sequence?: number;
|
||||
recurrenceRules?: RecurrenceRule[];
|
||||
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceRules?: RecurrenceRule[] | null;
|
||||
recurrenceOverrides?: Record<string, Partial<CalendarEvent>> | null;
|
||||
excludedRecurrenceRules?: RecurrenceRule[] | null;
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
|
||||
// when `calendarIds` is remapped to namespaced store ids for shared events.
|
||||
originalCalendarIds?: Record<string, boolean>;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
virtualLocations?: Record<string, VirtualLocation>;
|
||||
alerts?: Record<string, Alert> | null;
|
||||
locations?: Record<string, EventLocation> | null;
|
||||
virtualLocations?: Record<string, VirtualLocation> | null;
|
||||
links?: Record<string, { href: string; rel?: string }>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
// Task-only (RFC 8984 JSTask) fields.
|
||||
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
|
||||
due?: string;
|
||||
progressUpdated?: string;
|
||||
due?: string | null;
|
||||
priority?: number;
|
||||
percentComplete?: number;
|
||||
color?: string;
|
||||
@@ -474,6 +523,8 @@ export interface CalendarEvent {
|
||||
// primary account; set for events on calendars shared with the user so
|
||||
// mutations can be routed to the owning account.
|
||||
accountId?: string;
|
||||
// Client-only: true when the event lives on a calendar shared with the user.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
@@ -485,8 +536,6 @@ export interface CalendarRights {
|
||||
mayRSVP?: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete?: boolean;
|
||||
// Legacy short flag still read by the sidebar drawer.
|
||||
mayWrite?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
@@ -499,9 +548,18 @@ export interface Calendar {
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: CalendarRights;
|
||||
// Principals this (owned) calendar is shared with and their rights.
|
||||
shareWith?: Record<string, CalendarRights> | null;
|
||||
// Alerts applied to events that set `useDefaultAlerts` (RFC 8620 calendars).
|
||||
defaultAlertsWithTime?: Record<string, Alert> | null;
|
||||
defaultAlertsWithoutTime?: Record<string, Alert> | null;
|
||||
// Client-only: JMAP account the calendar was fetched from. Absent for the
|
||||
// primary account; set for calendars shared with the user.
|
||||
accountId?: string;
|
||||
// Client-only: the real JMAP id of a shared calendar, before its `id` was
|
||||
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
|
||||
// and event queries must use this raw id against the owning account.
|
||||
originalId?: string;
|
||||
// Client-only: true when the calendar lives in another account (shared
|
||||
// with the user by its owner).
|
||||
isShared?: boolean;
|
||||
@@ -522,7 +580,9 @@ export interface FileNode {
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
// Last modification time (UTCDate). Older builds asked for a non-existent
|
||||
// `updated` property, which the server silently left undefined (#700).
|
||||
modified?: string;
|
||||
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||
// principals capability and the properties are requested explicitly. A node
|
||||
// is shared-out when `shareWith` has entries; `myRights` describes what the
|
||||
@@ -576,6 +636,17 @@ export interface PushSubscription {
|
||||
verificationCode?: string | null;
|
||||
expires?: string | null;
|
||||
types?: string[] | null;
|
||||
emailPush?: Record<string, EmailPushConfig> | null;
|
||||
}
|
||||
|
||||
// draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter`
|
||||
// against every newly delivered message and only pushes when it matches, so a
|
||||
// client can keep spam (Junk) out of its push channel server-side. Keyed by
|
||||
// account id on PushSubscription.emailPush.
|
||||
export interface EmailPushConfig {
|
||||
filter: Record<string, unknown> | null;
|
||||
properties: string[];
|
||||
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
||||
}
|
||||
|
||||
// ─── Capability URNs ────────────────────────────────────
|
||||
@@ -592,4 +663,5 @@ export const CAPABILITIES = {
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
PRINCIPALS: 'urn:ietf:params:jmap:principals',
|
||||
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
|
||||
EMAIL_PUSH: 'urn:ietf:params:jmap:emailpush',
|
||||
} as const;
|
||||
|
||||
+470
-126
@@ -1,21 +1,39 @@
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
|
||||
import { jmapClient, type StoredCredentials } from './jmap-client';
|
||||
import {
|
||||
jmapClient, rewriteSessionUrl, extractOrigin, parseRetryAfter, REQUEST_TIMEOUT_MS,
|
||||
type StoredCredentials,
|
||||
} from './jmap-client';
|
||||
import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
|
||||
// Aggregated inbox across every logged-in account. Because the JMAP client is
|
||||
// a single-account singleton (one live session at a time), we fetch each
|
||||
// account's inbox "detached" here — reading its stored credentials directly and
|
||||
// issuing requests against its own API URL — so the active account's live
|
||||
// session is never disturbed.
|
||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||
// Unread / Starred). Because the JMAP client is a single-account singleton
|
||||
// (one live session at a time), the *active* registry account goes through
|
||||
// the live client while every other account is fetched "detached" — reading
|
||||
// its stored credentials directly and posting to its own API URL — so the
|
||||
// active session is never disturbed. Session discovery and the per-account
|
||||
// mailbox lists are cached for the session so a refresh is one Email/query +
|
||||
// Email/get per account instead of four round-trips.
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
];
|
||||
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAILBOX_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||
export type CrossView = 'all' | 'unread' | 'starred';
|
||||
|
||||
/** Roles left out of the cross-account All mail / Unread / Starred views. */
|
||||
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
|
||||
]);
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
/** Registry account id this message belongs to (not the JMAP account id). */
|
||||
@@ -23,40 +41,104 @@ export interface UnifiedEmail extends Email {
|
||||
/**
|
||||
* 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.
|
||||
* the message came from a group inbox. Carried so opening/acting can target
|
||||
* the right account via an Email/get or Email/set 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;
|
||||
/** Name of the folder the message was found in (cross views). */
|
||||
sourceFolder?: string;
|
||||
}
|
||||
|
||||
export interface UnifiedInboxResult {
|
||||
emails: UnifiedEmail[];
|
||||
/** accountId → error message for accounts that could not be fetched. */
|
||||
errors: Record<string, string>;
|
||||
/** Per-target paging cursors to pass back as `positions` for the next page. */
|
||||
positions: Record<string, number>;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchOptions {
|
||||
/** Per-role view (default inbox) — ignored when `view` is set. */
|
||||
role?: UnifiedRole;
|
||||
/** Cross-folder view over inbox + custom folders. */
|
||||
view?: CrossView;
|
||||
/** Also scan group/shared accounts reachable through each login. */
|
||||
includeGroup?: boolean;
|
||||
/** Free-text search ANDed into every account's query. */
|
||||
query?: string;
|
||||
/** Cursors from the previous page (`UnifiedInboxResult.positions`). */
|
||||
positions?: Record<string, number>;
|
||||
}
|
||||
|
||||
// ── Detached transport ──────────────────────────────────────────────────
|
||||
|
||||
function authHeaderFor(creds: StoredCredentials): string {
|
||||
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
|
||||
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
/** Thrown when a detached account's server rate-limited us (429). */
|
||||
export class UnifiedRateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
super('Rate limited');
|
||||
this.name = 'UnifiedRateLimitError';
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
|
||||
// the origin we actually connected to (servers often self-report unreachable
|
||||
// container-internal hosts).
|
||||
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
|
||||
const serverOrigin = originOf(serverUrl);
|
||||
const apiOrigin = originOf(session.apiUrl);
|
||||
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
|
||||
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
|
||||
/**
|
||||
* `fetch` with the same deadline the live client applies
|
||||
* (`jmapClient.authenticatedFetch`): an AbortController-backed timeout, 401
|
||||
* → "Session expired", 429 → rate-limit error carrying Retry-After.
|
||||
*/
|
||||
export async function fetchWithDeadline(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
): Promise<Response> {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await secureFetch(url, { ...init, signal: controller?.signal });
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers?.get?.('Retry-After') ?? null;
|
||||
throw new UnifiedRateLimitError(parseRetryAfter(retryAfter));
|
||||
}
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
return response;
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type MethodResponses = Array<[string, Record<string, any>, string]>;
|
||||
|
||||
interface Transport {
|
||||
post: (methodCalls: JMAPMethodCall[]) => Promise<MethodResponses>;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
): Promise<MethodResponses> {
|
||||
const response = await fetchWithDeadline(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }),
|
||||
});
|
||||
const body = await response.json();
|
||||
return (body.methodResponses ?? []) as MethodResponses;
|
||||
}
|
||||
|
||||
function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
@@ -68,6 +150,22 @@ function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
// Same rule as JMAPClient.getSharedMailAccounts(): Stalwart doesn't always
|
||||
// populate `accountCapabilities` on shared accounts, so an account counts as
|
||||
// mail-capable when it advertises the mail capability OR is non-personal.
|
||||
function sharedMailAccountsOf(session: JMAPSession, primaryId: string): { id: string; name: string }[] {
|
||||
const out: { id: string; name: string }[] = [];
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryId) continue;
|
||||
const advertisesMail = info.accountCapabilities
|
||||
? CAPABILITIES.MAIL in info.accountCapabilities
|
||||
: false;
|
||||
if (!advertisesMail && info.isPersonal) continue;
|
||||
out.push({ id, name: info.name || id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
|
||||
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
|
||||
async function ensureFreshCredentials(
|
||||
@@ -101,7 +199,10 @@ async function ensureFreshCredentials(
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
const current = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!current || current.accessToken !== updated.accessToken) {
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
}
|
||||
return updated;
|
||||
} catch {
|
||||
// Fall back to the existing (possibly expired) token; the request will
|
||||
@@ -110,139 +211,260 @@ async function ensureFreshCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
using: string[],
|
||||
): Promise<Array<[string, Record<string, any>, string]>> {
|
||||
const response = await secureFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
const body = await response.json();
|
||||
return body.methodResponses ?? [];
|
||||
// ── Per-account session + mailbox cache ────────────────────────────────
|
||||
|
||||
interface AccountEntry {
|
||||
accountId: string;
|
||||
transport: Transport;
|
||||
primaryJmapId: string;
|
||||
shared: { id: string; name: string }[];
|
||||
/** Registry account served by the live jmapClient right now. */
|
||||
live: boolean;
|
||||
discoveredAt: number;
|
||||
mailboxes: Map<string, { list: Mailbox[]; at: number }>;
|
||||
}
|
||||
|
||||
// Fetch the most recent inbox messages for a single JMAP account reachable via
|
||||
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
|
||||
// them with registry/sharing metadata.
|
||||
async function fetchInboxEmailsForJmapAccount(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
jmapAccountId: string,
|
||||
limit: number,
|
||||
): Promise<Email[]> {
|
||||
const mailboxResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
|
||||
if (!inbox) return [];
|
||||
const entries = new Map<string, AccountEntry>();
|
||||
|
||||
const queryResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/query', {
|
||||
accountId: jmapAccountId,
|
||||
filter: { inMailbox: inbox.id },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const getResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
/** Forget every cached session/mailbox list (logout, credential change, tests). */
|
||||
export function resetUnifiedCache(accountId?: string): void {
|
||||
if (accountId) entries.delete(accountId);
|
||||
else entries.clear();
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
includeGroup: boolean,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
function isLiveAccount(accountId: string): boolean {
|
||||
if (!jmapClient.isConnected) return false;
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return false;
|
||||
// Same rule as generateAccountId(username, serverUrl) in lib/account-utils.
|
||||
let host = serverUrl;
|
||||
try { host = new URL(serverUrl).hostname; } catch { host = serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); }
|
||||
return `${username}@${host}` === accountId;
|
||||
}
|
||||
|
||||
async function entryFor(accountId: string): Promise<AccountEntry> {
|
||||
const live = isLiveAccount(accountId);
|
||||
const cached = entries.get(accountId);
|
||||
if (cached && cached.live === live && Date.now() - cached.discoveredAt < SESSION_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (live && jmapClient.currentSession) {
|
||||
const entry: AccountEntry = {
|
||||
accountId,
|
||||
live: true,
|
||||
transport: { post: async (calls) => (await jmapClient.request(calls)).methodResponses as MethodResponses },
|
||||
primaryJmapId: jmapClient.accountId,
|
||||
shared: jmapClient.getSharedMailAccounts(),
|
||||
discoveredAt: Date.now(),
|
||||
mailboxes: cached?.mailboxes ?? new Map(),
|
||||
};
|
||||
entries.set(accountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
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`, {
|
||||
const sessionRes = await fetchWithDeadline(`${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);
|
||||
// Point the advertised apiUrl at the origin we actually connected to
|
||||
// (servers often self-report unreachable container-internal hosts) and
|
||||
// resolve relative URLs — same as the live client does.
|
||||
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(baseUrl)) ?? session.apiUrl;
|
||||
const primaryJmapId = resolveJmapAccountId(session);
|
||||
if (!primaryJmapId) throw new Error('No mail account in session');
|
||||
|
||||
// Always include the user's own primary inbox. When group inboxes are on,
|
||||
// also scan every other (non-personal / shared) account reachable in the
|
||||
// session — these are the group/shared mailboxes the user belongs to.
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryJmapId) continue;
|
||||
// Only accounts that expose the Mail capability can have an inbox.
|
||||
const hasMail =
|
||||
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
|
||||
if (!hasMail) continue;
|
||||
targets.push({ jmapId: id, isShared: true, label: info.name });
|
||||
}
|
||||
}
|
||||
|
||||
const perTarget = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
|
||||
return list.map<UnifiedEmail>((e) => ({
|
||||
...e,
|
||||
sourceAccountId: accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
}));
|
||||
} catch {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's inbox; skip it and keep the others.
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return perTarget.flat();
|
||||
const entry: AccountEntry = {
|
||||
accountId,
|
||||
live: false,
|
||||
transport: {
|
||||
post: async (calls) => {
|
||||
try {
|
||||
return await jmapPost(apiUrl, authHeader, calls);
|
||||
} catch (err) {
|
||||
// A dead session must not be reused for the next refresh.
|
||||
if (err instanceof Error && err.message === 'Session expired') entries.delete(accountId);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
primaryJmapId,
|
||||
shared: sharedMailAccountsOf(session, primaryJmapId),
|
||||
discoveredAt: Date.now(),
|
||||
mailboxes: cached?.mailboxes ?? new Map(),
|
||||
};
|
||||
entries.set(accountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function mailboxesFor(entry: AccountEntry, jmapAccountId: string, force = false): Promise<Mailbox[]> {
|
||||
const cached = entry.mailboxes.get(jmapAccountId);
|
||||
if (cached && !force && Date.now() - cached.at < MAILBOX_CACHE_TTL_MS) return cached.list;
|
||||
const res = await entry.transport.post([
|
||||
['Mailbox/get', {
|
||||
accountId: jmapAccountId,
|
||||
properties: ['id', 'role', 'name', 'parentId', 'unreadEmails', 'totalEmails', 'myRights'],
|
||||
}, '0'],
|
||||
]);
|
||||
const [name, body] = res[0] ?? [];
|
||||
if (name !== 'Mailbox/get') throw new Error(body?.description || 'Mailbox/get failed');
|
||||
const list = ((body.list as Mailbox[]) ?? []).map((m) => ({ ...m, accountId: jmapAccountId }));
|
||||
entry.mailboxes.set(jmapAccountId, { list, at: Date.now() });
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Cached mailbox lists per JMAP account of one registry account (for counts). */
|
||||
export function cachedUnifiedMailboxes(accountId: string): Array<{ jmapAccountId: string; mailboxes: Mailbox[] }> {
|
||||
const entry = entries.get(accountId);
|
||||
if (!entry) return [];
|
||||
return [...entry.mailboxes.entries()].map(([jmapAccountId, v]) => ({ jmapAccountId, mailboxes: v.list }));
|
||||
}
|
||||
|
||||
// ── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
function findByRole(mailboxes: Mailbox[], role: UnifiedRole): Mailbox | undefined {
|
||||
if (role === 'junk') return mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
return mailboxes.find((m) => m.role === role);
|
||||
}
|
||||
|
||||
/** Folders of one JMAP account that feed the cross views: inbox + custom folders. */
|
||||
export function crossIncludedMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
return mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
|
||||
}
|
||||
|
||||
function buildFilter(
|
||||
mailboxes: Mailbox[],
|
||||
opts: UnifiedFetchOptions,
|
||||
): Record<string, unknown> | null {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (opts.view) {
|
||||
const included = crossIncludedMailboxes(mailboxes);
|
||||
if (included.length === 0) return null;
|
||||
conditions.push(
|
||||
included.length === 1
|
||||
? { inMailbox: included[0].id }
|
||||
: { operator: 'OR', conditions: included.map((m) => ({ inMailbox: m.id })) },
|
||||
);
|
||||
if (opts.view === 'unread') conditions.push({ notKeyword: '$seen' });
|
||||
if (opts.view === 'starred') conditions.push({ hasKeyword: '$flagged' });
|
||||
} else {
|
||||
const target = findByRole(mailboxes, opts.role ?? 'inbox');
|
||||
if (!target) return null;
|
||||
conditions.push({ inMailbox: target.id });
|
||||
}
|
||||
const q = opts.query?.trim();
|
||||
if (q) conditions.push({ text: toWildcardQuery(q) });
|
||||
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
function targetKey(accountId: string, jmapAccountId: string): string {
|
||||
return `${accountId}|${jmapAccountId}`;
|
||||
}
|
||||
|
||||
async function fetchTarget(
|
||||
entry: AccountEntry,
|
||||
target: { jmapId: string; isShared: boolean; label?: string },
|
||||
opts: UnifiedFetchOptions,
|
||||
position: number,
|
||||
limit: number,
|
||||
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> {
|
||||
const mailboxes = await mailboxesFor(entry, target.jmapId);
|
||||
const filter = buildFilter(mailboxes, opts);
|
||||
if (!filter) return { emails: [], hasMore: false };
|
||||
|
||||
const res = await entry.transport.post([
|
||||
['Email/query', {
|
||||
accountId: target.jmapId,
|
||||
filter,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
position,
|
||||
limit,
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId: target.jmapId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: EMAIL_LIST_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const [qName, qBody] = res[0] ?? [];
|
||||
if (qName !== 'Email/query') throw new Error(qBody?.description || 'Email/query failed');
|
||||
const ids = (qBody.ids as string[]) ?? [];
|
||||
const total = typeof qBody.total === 'number' ? qBody.total : undefined;
|
||||
const list = ((res[1]?.[1]?.list as Email[]) ?? []);
|
||||
const byId = new Map(list.map((e) => [e.id, e]));
|
||||
const nameOf = (e: Email): string | undefined =>
|
||||
mailboxes.find((m) => e.mailboxIds?.[m.id])?.name;
|
||||
const emails = ids.flatMap((id) => {
|
||||
const e = byId.get(id);
|
||||
if (!e) return [];
|
||||
return [{
|
||||
...e,
|
||||
sourceAccountId: entry.accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
sourceFolder: opts.view ? nameOf(e) : undefined,
|
||||
} as UnifiedEmail];
|
||||
});
|
||||
const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit;
|
||||
return { emails, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the unified view over `accountIds`. Every target (a registry
|
||||
* account's own mailbox plus, with `includeGroup`, each group/shared account
|
||||
* reachable through it) is queried at its own `positions` cursor and the
|
||||
* results are merged newest-first. Per-account failures are collected in
|
||||
* `errors` while the others still show.
|
||||
*/
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
opts: UnifiedFetchOptions = {},
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const { includeGroup = false } = opts;
|
||||
const errors: Record<string, string> = {};
|
||||
const positions: Record<string, number> = { ...(opts.positions ?? {}) };
|
||||
let hasMore = false;
|
||||
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
accountIds.map(async (accountId) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
|
||||
const entry = await entryFor(accountId);
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: entry.primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const acc of entry.shared) targets.push({ jmapId: acc.id, isShared: true, label: acc.name });
|
||||
}
|
||||
const perTarget = await Promise.all(targets.map(async (target) => {
|
||||
const key = targetKey(accountId, target.jmapId);
|
||||
const position = positions[key] ?? 0;
|
||||
try {
|
||||
const page = await fetchTarget(entry, target, opts, position, perAccountLimit);
|
||||
positions[key] = position + page.emails.length;
|
||||
if (page.hasMore) hasMore = true;
|
||||
return page.emails;
|
||||
} catch (err) {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's view; skip it and keep the others. Own-mail failures
|
||||
// are the account's failure.
|
||||
if (!target.isShared) throw err;
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}));
|
||||
return perTarget.flat();
|
||||
} catch (err) {
|
||||
errors[id] = err instanceof Error ? err.message : 'Failed to load';
|
||||
errors[accountId] = err instanceof Error ? err.message : 'Failed to load';
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
@@ -250,5 +472,127 @@ export async function fetchUnifiedInbox(
|
||||
const emails = settled
|
||||
.flat()
|
||||
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
return { emails, errors };
|
||||
return { emails, errors, positions, hasMore };
|
||||
}
|
||||
|
||||
// ── Actions routed to the owning account ────────────────────────────────
|
||||
|
||||
async function emailSet(
|
||||
accountId: string,
|
||||
jmapAccountId: string,
|
||||
update: Record<string, Record<string, unknown>>,
|
||||
destroy?: string[],
|
||||
): Promise<void> {
|
||||
const entry = await entryFor(accountId);
|
||||
const args: Record<string, unknown> = { accountId: jmapAccountId };
|
||||
if (Object.keys(update).length > 0) args.update = update;
|
||||
if (destroy && destroy.length > 0) args.destroy = destroy;
|
||||
const res = await entry.transport.post([['Email/set', args, '0']]);
|
||||
const [name, body] = res[0] ?? [];
|
||||
if (name !== 'Email/set') throw new Error(body?.description || 'Email/set failed');
|
||||
const failures = { ...(body.notUpdated ?? {}), ...(body.notDestroyed ?? {}) } as Record<string, { type?: string; description?: string }>;
|
||||
const first = Object.entries(failures)[0];
|
||||
if (first) throw new Error(`${first[1]?.type ?? 'error'}${first[1]?.description ? `: ${first[1].description}` : ''}`);
|
||||
}
|
||||
|
||||
function groupByOwner(emails: UnifiedEmail[]): Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }> {
|
||||
const groups = new Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }>();
|
||||
for (const e of emails) {
|
||||
const key = targetKey(e.sourceAccountId, e.jmapAccountId);
|
||||
const g = groups.get(key);
|
||||
if (g) g.emails.push(e);
|
||||
else groups.set(key, { accountId: e.sourceAccountId, jmapAccountId: e.jmapAccountId, emails: [e] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Patch keywords (`{ $seen: true, $flagged: null }`) on messages of any account. */
|
||||
export async function patchUnifiedKeywords(
|
||||
emails: UnifiedEmail[],
|
||||
patch: Record<string, boolean | null>,
|
||||
): Promise<void> {
|
||||
const pointer: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) pointer[keywordPointer(k)] = v === false ? null : v;
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of g.emails) update[e.id] = { ...pointer };
|
||||
await emailSet(g.accountId, g.jmapAccountId, update);
|
||||
}
|
||||
}
|
||||
|
||||
export type UnifiedMoveTarget = 'archive' | 'trash' | 'junk' | 'inbox';
|
||||
|
||||
/**
|
||||
* Move messages into the role folder of their *own* account (archive into
|
||||
* the owner's Archive, …). Spam/not-spam also flip `$junk`/`$notjunk` (#850).
|
||||
* Throws when an owning account lacks the target folder.
|
||||
*/
|
||||
export async function moveUnifiedEmails(
|
||||
emails: UnifiedEmail[],
|
||||
target: UnifiedMoveTarget,
|
||||
opts: { markRead?: boolean } = {},
|
||||
): Promise<void> {
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const entry = await entryFor(g.accountId);
|
||||
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
|
||||
const dest = findByRole(mailboxes, target);
|
||||
if (!dest) throw new Error(`No ${target} folder in this account`);
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of g.emails) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const id of Object.keys(e.mailboxIds ?? {})) {
|
||||
if (id !== dest.id) patch[mailboxPointer(id)] = null;
|
||||
}
|
||||
patch[mailboxPointer(dest.id)] = true;
|
||||
if (target === 'junk') {
|
||||
patch[keywordPointer('$junk')] = true;
|
||||
patch[keywordPointer('$notjunk')] = null;
|
||||
} else if (target === 'inbox') {
|
||||
patch[keywordPointer('$junk')] = null;
|
||||
patch[keywordPointer('$notjunk')] = true;
|
||||
}
|
||||
if (opts.markRead) patch[keywordPointer('$seen')] = true;
|
||||
update[e.id] = patch;
|
||||
}
|
||||
await emailSet(g.accountId, g.jmapAccountId, update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete like the folder list does: move to the owner's Trash, or destroy
|
||||
* outright when the message already sits in Trash or `permanent` is set.
|
||||
*/
|
||||
export async function deleteUnifiedEmails(
|
||||
emails: UnifiedEmail[],
|
||||
opts: { permanent?: boolean; markRead?: boolean } = {},
|
||||
): Promise<{ destroyed: number; trashed: number }> {
|
||||
let destroyed = 0;
|
||||
let trashed = 0;
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const entry = await entryFor(g.accountId);
|
||||
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
|
||||
const trash = findByRole(mailboxes, 'trash');
|
||||
const toDestroy = g.emails.filter((e) => opts.permanent || !trash || !!(trash && e.mailboxIds?.[trash.id]));
|
||||
const toTrash = g.emails.filter((e) => !toDestroy.includes(e));
|
||||
if (toDestroy.length > 0 && !trash && !opts.permanent) {
|
||||
throw new Error('No Trash folder in this account');
|
||||
}
|
||||
if (toDestroy.length > 0) {
|
||||
await emailSet(g.accountId, g.jmapAccountId, {}, toDestroy.map((e) => e.id));
|
||||
destroyed += toDestroy.length;
|
||||
}
|
||||
if (toTrash.length > 0 && trash) {
|
||||
await moveUnifiedEmails(toTrash, 'trash', { markRead: opts.markRead });
|
||||
trashed += toTrash.length;
|
||||
}
|
||||
}
|
||||
return { destroyed, trashed };
|
||||
}
|
||||
|
||||
/** Whether a message sits in its account's Trash (drives the permanent-delete confirm). */
|
||||
export function isInUnifiedTrash(email: UnifiedEmail): boolean {
|
||||
const entry = entries.get(email.sourceAccountId);
|
||||
const list = entry?.mailboxes.get(email.jmapAccountId)?.list ?? [];
|
||||
const trash = findByRole(list, 'trash');
|
||||
return !!trash && !!email.mailboxIds?.[trash.id];
|
||||
}
|
||||
|
||||
+81
-14
@@ -6,6 +6,11 @@ export interface ReleaseAsset {
|
||||
size: number;
|
||||
}
|
||||
|
||||
// Mirrors the webmail's version-check severities: `security` and `deprecated`
|
||||
// releases show a non-dismissable banner. Read from a `severity: <value>` line
|
||||
// in the release body (GitHub Releases has no structured field for it).
|
||||
export type UpdateSeverity = 'normal' | 'security' | 'deprecated';
|
||||
|
||||
export interface LatestRelease {
|
||||
tag: string;
|
||||
name: string;
|
||||
@@ -13,14 +18,39 @@ export interface LatestRelease {
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
apkAsset: ReleaseAsset | null;
|
||||
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
|
||||
// asset or from the release body. Null when the release didn't publish one;
|
||||
// the installer falls back to relying solely on Android's package signing
|
||||
// check in that case (still strong, but less defense-in-depth).
|
||||
// Companion `*.apk.sha256` asset, fetched lazily on Install (see
|
||||
// resolveApkSha256) so the periodic check costs one request.
|
||||
sha256Asset: ReleaseAsset | null;
|
||||
// Lower-case hex SHA-256 of the APK when the release body lists one. Null
|
||||
// when it doesn't; the installer then relies on the companion asset or,
|
||||
// failing that, solely on Android's package signing check.
|
||||
apkSha256: string | null;
|
||||
severity: UpdateSeverity;
|
||||
// Optional advisory link from an `advisory: <url>` line in the body.
|
||||
advisoryUrl: string | null;
|
||||
}
|
||||
|
||||
/** GitHub answered 403/429 (unauthenticated limit is 60 req/h/IP). */
|
||||
export class UpdateRateLimitedError extends Error {
|
||||
constructor(status: number) {
|
||||
super(`GitHub API rate limited (${status})`);
|
||||
this.name = 'UpdateRateLimitedError';
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
|
||||
const SEVERITY_RE = /^[ \t]*(?:[-*][ \t]*)?severity[ \t]*:[ \t]*(normal|security|deprecated)\b/im;
|
||||
const ADVISORY_RE = /^[ \t]*(?:[-*][ \t]*)?advisory[ \t]*:[ \t]*(https?:\/\/\S+)/im;
|
||||
|
||||
export function parseSeverity(body: string): UpdateSeverity {
|
||||
const m = SEVERITY_RE.exec(body || '');
|
||||
return (m?.[1]?.toLowerCase() as UpdateSeverity | undefined) ?? 'normal';
|
||||
}
|
||||
|
||||
export function parseAdvisoryUrl(body: string): string | null {
|
||||
const m = ADVISORY_RE.exec(body || '');
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function extractSha256(body: string, apkName: string | undefined): string | null {
|
||||
if (!body) return null;
|
||||
@@ -41,7 +71,7 @@ function extractSha256(body: string, apkName: string | undefined): string | null
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
|
||||
async function fetchCompanionSha256(asset: ReleaseAsset | null): Promise<string | null> {
|
||||
if (!asset) return null;
|
||||
try {
|
||||
const res = await fetch(asset.browser_download_url);
|
||||
@@ -54,12 +84,47 @@ async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The checksum to verify the APK against: the release body's hash when it
|
||||
* lists one, else the companion `.sha256` asset (one extra request, made
|
||||
* only when the user actually installs).
|
||||
*/
|
||||
export async function resolveApkSha256(release: LatestRelease): Promise<string | null> {
|
||||
if (release.apkSha256) return release.apkSha256;
|
||||
return fetchCompanionSha256(release.sha256Asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn GitHub release markdown into readable plain text: headings, emphasis,
|
||||
* links (kept as their label), code spans and list bullets are unwrapped.
|
||||
* Also strips the `severity:` / `advisory:` control lines.
|
||||
*/
|
||||
export function stripMarkdown(body: string): string {
|
||||
return (body || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(SEVERITY_RE, '')
|
||||
.replace(ADVISORY_RE, '')
|
||||
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```\w*\n?/g, ''))
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, '$2')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/^[ \t]*[-*+][ \t]+/gm, '• ')
|
||||
.replace(/^[ \t]*>[ \t]?/gm, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
if (res.status === 403 || res.status === 429) throw new UpdateRateLimitedError(res.status);
|
||||
throw new Error(`GitHub API ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
@@ -70,22 +135,24 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
body: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
const assets = Array.isArray(json.assets) ? json.assets : [];
|
||||
const apkAsset = assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
const sha256Asset = apkAsset
|
||||
? json.assets.find(
|
||||
? assets.find(
|
||||
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
|
||||
)
|
||||
: undefined;
|
||||
const apkSha256 =
|
||||
(await fetchCompanionSha256(sha256Asset)) ??
|
||||
extractSha256(json.body || '', apkAsset?.name);
|
||||
) ?? null
|
||||
: null;
|
||||
const body = json.body || '';
|
||||
return {
|
||||
tag: json.tag_name,
|
||||
name: json.name || json.tag_name,
|
||||
htmlUrl: json.html_url,
|
||||
publishedAt: json.published_at,
|
||||
body: json.body || '',
|
||||
body,
|
||||
apkAsset,
|
||||
apkSha256,
|
||||
sha256Asset,
|
||||
apkSha256: extractSha256(body, apkAsset?.name),
|
||||
severity: parseSeverity(body),
|
||||
advisoryUrl: parseAdvisoryUrl(body),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
|
||||
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
|
||||
import { AlertTriangle } from 'lucide-react-native';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface DialogInput {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
// Extra TextInput props (keyboardType, autoCapitalize, secureTextEntry…).
|
||||
props?: Omit<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'style'>;
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
// Turns the confirm dialog into a prompt (webmail prompt-dialog.tsx).
|
||||
input?: DialogInput;
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -30,13 +42,18 @@ export default function Dialog({
|
||||
title,
|
||||
message,
|
||||
variant = 'default',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText,
|
||||
cancelText,
|
||||
input,
|
||||
confirmDisabled,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
|
||||
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
@@ -47,15 +64,33 @@ export default function Dialog({
|
||||
<AlertTriangle size={20} color={c.error} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
<Text style={styles.title} accessibilityRole="header">{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
{input ? (
|
||||
<TextInput
|
||||
value={input.value}
|
||||
onChangeText={input.onChangeText}
|
||||
placeholder={input.placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoFocus
|
||||
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
|
||||
accessibilityLabel={input.placeholder ?? title}
|
||||
style={styles.input}
|
||||
{...input.props}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||
{cancelText}
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
|
||||
{confirmText}
|
||||
<Button
|
||||
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
onPress={onConfirm}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -107,6 +142,17 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 10,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
+209
-114
@@ -1,32 +1,31 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs,
|
||||
} from '../lib/email-html';
|
||||
import { pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||
import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse';
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
|
||||
// skipped so we don't freeze the JS thread base64-encoding megabytes.
|
||||
const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
||||
}
|
||||
return global.btoa ? global.btoa(binary) : btoa(binary);
|
||||
}
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface EmailBodyViewProps {
|
||||
email: Email;
|
||||
senderEmail?: string;
|
||||
/** Owning account when the message lives in a shared/group mailbox. */
|
||||
jmapAccountId?: string;
|
||||
// The body is a native WebView, which on Android swallows horizontal touches
|
||||
// before the surrounding pager can see them. We detect a clear horizontal
|
||||
// swipe inside the page and report it here so the pager can change messages.
|
||||
@@ -36,22 +35,14 @@ interface EmailBodyViewProps {
|
||||
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
|
||||
// scroll the pane away or switch mails.
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
function extractHtmlBody(email: Email): string | null {
|
||||
for (const part of email.htmlBody ?? []) {
|
||||
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTextBody(email: Email): string | null {
|
||||
for (const part of email.textBody ?? []) {
|
||||
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
/** Per-message override of the light/dark rendering (More sheet toggle). */
|
||||
themeOverride?: 'light' | 'dark' | null;
|
||||
/**
|
||||
* Replace the message's own body - used when the outer message is an empty
|
||||
* envelope around a message/rfc822 attachment or a TNEF winmail.dat and the
|
||||
* real content was extracted client-side.
|
||||
*/
|
||||
bodyOverride?: { html?: string | null; text?: string | null } | null;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
@@ -78,10 +69,13 @@ const DARK_REINVERT_SCRIPT = `
|
||||
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
|
||||
var computed = win.getComputedStyle(el);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children.
|
||||
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
// Re-invert so the background image returns to its original
|
||||
// appearance. Matches the CSS background-image rules: re-invert even
|
||||
// when media is nested, then cancel the per-image re-invert on that
|
||||
// media so it isn't left double-inverted.
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
|
||||
.forEach(function (m) { m.style.filter = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +148,57 @@ const DARK_REINVERT_SCRIPT = `
|
||||
})();
|
||||
`;
|
||||
|
||||
// Image fix-ups, mirroring the webmail's post-render passes:
|
||||
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
|
||||
// blocked newsletter doesn't show rows of empty cells;
|
||||
// - hide images that fail to load (dead/unreachable URLs) rather than leave
|
||||
// the engine's broken-image glyph and alt text as stray labels.
|
||||
const IMG_FIXUP_SCRIPT = `
|
||||
(function () {
|
||||
if (document.__rnImgFixupDone) return;
|
||||
var doc = document;
|
||||
if (!doc.body) return;
|
||||
document.__rnImgFixupDone = true;
|
||||
|
||||
doc.querySelectorAll('img[data-blocked-src]').forEach(function (img) {
|
||||
var el = img.parentElement;
|
||||
while (el && el !== doc.body) {
|
||||
if (el.tagName === 'TD' || el.tagName === 'TH' ||
|
||||
(el.tagName === 'DIV' && el.parentElement && el.parentElement.tagName === 'TD')) {
|
||||
var hasText = (el.textContent || '').replace(/[\\s\\u00A0]+/g, '').trim();
|
||||
var hasMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||
var hasLinks = el.querySelector('a[href]');
|
||||
if (!hasText && !hasMedia && !hasLinks) {
|
||||
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||||
el.style.display = 'none';
|
||||
el.style.height = '0';
|
||||
el.style.padding = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
|
||||
el = el.parentElement;
|
||||
}
|
||||
});
|
||||
|
||||
function hideIfBroken(img) {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
}
|
||||
doc.querySelectorAll('img').forEach(function (img) {
|
||||
if (img.hasAttribute('data-blocked-src')) return;
|
||||
if (img.complete) {
|
||||
hideIfBroken(img);
|
||||
} else {
|
||||
img.addEventListener('error', function () { img.style.display = 'none'; }, { once: true });
|
||||
img.addEventListener('load', function () { hideIfBroken(img); }, { once: true });
|
||||
}
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const HEIGHT_REPORTER = `
|
||||
(function () {
|
||||
// One-time: wrap body contents in a block-level div we can measure reliably.
|
||||
@@ -457,15 +502,20 @@ const PINCH_ZOOM = `
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
|
||||
export default function EmailBodyView({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
@@ -484,9 +534,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
const addressBookEmails = React.useMemo(() => {
|
||||
if (!trustedSendersAddressBook) return null;
|
||||
const out = new Set<string>();
|
||||
for (const c of contacts) {
|
||||
if (!c.emails) continue;
|
||||
for (const e of Object.values(c.emails)) {
|
||||
for (const card of contacts) {
|
||||
if (!card.emails) continue;
|
||||
for (const e of Object.values(card.emails)) {
|
||||
if (e?.address) out.add(e.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -494,18 +544,21 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
}, [contacts, trustedSendersAddressBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark.
|
||||
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
// per-message toggle in the More sheet overrides both.
|
||||
const renderAsDark = themeOverride
|
||||
? themeOverride === 'dark'
|
||||
: !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||||
|
||||
const html = React.useMemo(() => extractHtmlBody(email), [email]);
|
||||
const text = React.useMemo(() => extractTextBody(email), [email]);
|
||||
// Prefer textBody when the HTML is a minimal auto-generated wrapper that would
|
||||
// collapse newlines (mirrors webmail's hasMeaningfulHtmlBody fallback).
|
||||
const rawHtml = React.useMemo(
|
||||
() => (html && (!text || hasMeaningfulHtmlBody(html)) ? html : null),
|
||||
[html, text],
|
||||
);
|
||||
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
|
||||
// RFC 8621 §4.1.4: an HTML-only message exposes the same part in textBody
|
||||
// and htmlBody, so the text part is only an alternative when its partId
|
||||
// differs (native #46). `pickEmailBody` routes by part type and id.
|
||||
const picked = React.useMemo(() => {
|
||||
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
||||
return pickEmailBody(email);
|
||||
}, [email, bodyOverride]);
|
||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
const text = picked.text;
|
||||
const trusted = senderEmail
|
||||
? isSenderTrusted(senderEmail)
|
||||
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|
||||
@@ -518,14 +571,12 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
setAllowOnce(false);
|
||||
}, [email.id]);
|
||||
|
||||
// Policy-driven gate (like the webmail's `externalBlocked`): the strict CSP
|
||||
// is in effect whenever the policy is not "allow" and the sender is not
|
||||
// trusted, regardless of what the detection pass finds - the pass only
|
||||
// drives the banner and the placeholder swap.
|
||||
const shouldBlock =
|
||||
hasRemote &&
|
||||
!trusted &&
|
||||
!allowOnce &&
|
||||
externalContentPolicy !== 'allow';
|
||||
|
||||
const showBanner =
|
||||
hasRemote &&
|
||||
!!rawHtml &&
|
||||
!trusted &&
|
||||
!allowOnce &&
|
||||
externalContentPolicy !== 'allow';
|
||||
@@ -551,8 +602,7 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
}
|
||||
const toFetch = refs
|
||||
.map((ref) => ({ ref, att: byCid.get(ref) }))
|
||||
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att)
|
||||
.filter(({ att }) => !att.size || att.size <= MAX_INLINE_IMAGE_BYTES);
|
||||
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att);
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
@@ -561,10 +611,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
await Promise.all(
|
||||
toFetch.map(async ({ ref, att }) => {
|
||||
try {
|
||||
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
if (cancelled) return;
|
||||
const b64 = arrayBufferToBase64(buf);
|
||||
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
|
||||
const uri = await fetchInlineImageDataUri(att, jmapAccountId);
|
||||
if (cancelled || !uri) return;
|
||||
next[ref] = uri;
|
||||
} catch (err) {
|
||||
console.warn('[EmailBodyView] cid fetch failed', ref, err);
|
||||
}
|
||||
@@ -575,41 +624,54 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [rawHtml, email.attachments]);
|
||||
}, [rawHtml, email.attachments, jmapAccountId]);
|
||||
|
||||
const source = React.useMemo(() => {
|
||||
const quoteLabels = React.useMemo(() => ({
|
||||
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
|
||||
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
|
||||
}), [t]);
|
||||
|
||||
const prepared = React.useMemo(() => {
|
||||
if (rawHtml) {
|
||||
return {
|
||||
html: wrapEmailHtml(rawHtml, {
|
||||
blockRemoteImages: shouldBlock,
|
||||
cidMap,
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
const res = prepareEmailHtml(rawHtml, {
|
||||
blockRemoteImages: shouldBlock,
|
||||
cidMap,
|
||||
isDark: renderAsDark,
|
||||
messageSpacing,
|
||||
});
|
||||
return { ...res, isHtml: true };
|
||||
}
|
||||
const fallbackText = text ?? email.preview ?? '';
|
||||
if (!fallbackText) {
|
||||
return {
|
||||
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
const res = prepareEmailHtml(
|
||||
`<em style="color:#71717a">${t('email_viewer.no_body_content', '(No body content available)')}</em>`,
|
||||
{ isDark: renderAsDark },
|
||||
);
|
||||
return { ...res, isHtml: true };
|
||||
}
|
||||
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels);
|
||||
return {
|
||||
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
|
||||
html: wrapPlainTextEmail(safe, { isDark: renderAsDark, font: plainTextFont }),
|
||||
applyInversion: false,
|
||||
hasNativeDark: false,
|
||||
blockedExternal: false,
|
||||
isHtml: false,
|
||||
};
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark, messageSpacing, plainTextFont, quoteLabels, t]);
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships its
|
||||
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
|
||||
// stylesheet backgrounds) so it runs before the height reporter measures.
|
||||
const applyInversion =
|
||||
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
|
||||
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
|
||||
const showBanner = shouldBlock && prepared.blockedExternal;
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships
|
||||
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
|
||||
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
|
||||
+ SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[applyInversion],
|
||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||||
);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
@@ -637,31 +699,52 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect, and persist to the
|
||||
// dedicated "Trusted Senders" address book so the trust syncs across
|
||||
// devices (matches the webmail behavior).
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
addTrustedSender(senderEmail);
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
if (trustedSendersAddressBook) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
setAllowOnce(true);
|
||||
}
|
||||
};
|
||||
|
||||
// mailto: links inside the body open the app's own composer with the parsed
|
||||
// recipients/subject/body rather than the OS mail handler (webmail 1.8.1).
|
||||
const openMailto = (url: string) => {
|
||||
const fields = parseMailtoUrl(url);
|
||||
if (!fields) return;
|
||||
navigation.navigate('Compose', {
|
||||
prefillTo: fields.to.map((address) => ({ email: address })),
|
||||
prefillCc: fields.cc?.map((address) => ({ email: address })),
|
||||
prefillSubject: fields.subject,
|
||||
prefillBody: fields.body,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
{showBanner && (
|
||||
<View style={styles.banner}>
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||
<ImageIcon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Load external content</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||
<ShieldCheck size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Trust sender</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Text style={styles.bannerText}>
|
||||
{t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
|
||||
</Text>
|
||||
<View style={styles.bannerActions}>
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||
<ImageIcon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||
<ShieldCheck size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -679,9 +762,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
// reload the source mid-zoom).
|
||||
onZoomChange?.({ pinching: false, zoomed: false });
|
||||
// Re-inject after load to cover Android cases where
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
// again here is a no-op once the body has been processed.
|
||||
// `injectedJavaScript` runs too early to see the final layout. Every
|
||||
// pass guards itself so running it again here is a no-op once the
|
||||
// body has been processed.
|
||||
setTimeout(() => {
|
||||
webviewRef.current?.injectJavaScript(injectedJs);
|
||||
}, 50);
|
||||
@@ -708,10 +791,15 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
const url = request.url;
|
||||
if (!url || url === 'about:blank' || url.startsWith('data:')) {
|
||||
return true;
|
||||
// Only the initial about:blank document may load. A `data:` (or
|
||||
// any other) navigation would replace the email body with no way
|
||||
// back, so it is refused; images are reachable via the chips.
|
||||
if (!url || url === 'about:blank') return true;
|
||||
if (/^mailto:/i.test(url)) {
|
||||
openMailto(url);
|
||||
return false;
|
||||
}
|
||||
if (/^(https?|mailto|tel|sms):/i.test(url)) {
|
||||
if (/^(https?|tel|sms):/i.test(url)) {
|
||||
void Linking.openURL(url).catch(() => undefined);
|
||||
}
|
||||
return false;
|
||||
@@ -726,16 +814,23 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrapper: { width: '100%' },
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
bannerText: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
},
|
||||
bannerActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
bannerButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Mail } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Identity } from '../api/types';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface IdentitySheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
identities: Identity[];
|
||||
selectedIdentityId: string | null;
|
||||
onPick: (identity: Identity) => void;
|
||||
}
|
||||
|
||||
export function IdentitySheet({
|
||||
visible, onClose, identities, selectedIdentityId, onPick,
|
||||
}: IdentitySheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
const { t } = useLocaleStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.scrollList}>
|
||||
{identities.map((identity) => {
|
||||
const isSelected = identity.id === selectedIdentityId;
|
||||
return (
|
||||
<Pressable
|
||||
key={identity.id}
|
||||
onPress={() => {
|
||||
onPick(identity);
|
||||
onClose();
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.identityRow,
|
||||
pressed && styles.identityRowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconWrap}>
|
||||
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.identityDetails}>
|
||||
{!!identity.name && (
|
||||
<Text style={styles.identityName} numberOfLines={1}>
|
||||
{identity.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
|
||||
{identity.email}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View style={styles.checkWrap}>
|
||||
<Check size={16} color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '60%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
scrollList: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 52,
|
||||
},
|
||||
identityRowPressed: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
iconWrap: {
|
||||
marginRight: spacing.md,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityDetails: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityName: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
},
|
||||
identityEmail: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
identityEmailOnly: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
marginTop: 0,
|
||||
},
|
||||
checkWrap: {
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
||||
import { useColors } from '../theme/colors';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||
import type { Mailbox } from '../api/types';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
@@ -38,6 +40,7 @@ export function MoveSheet({
|
||||
}: MoveSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -88,7 +91,7 @@ export function MoveSheet({
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
@@ -98,7 +101,11 @@ export function MoveSheet({
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
// A shared account's header is a grouping row, not a folder, and
|
||||
// Drafts is never a move target (the webmail excludes it too).
|
||||
const canTarget =
|
||||
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent
|
||||
&& node.role !== 'drafts';
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
@@ -115,7 +122,7 @@ export function MoveSheet({
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
{node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -15,18 +16,24 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const online = useNetworkStore((s) => s.online);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// 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`
|
||||
? t(
|
||||
'offline.queued_changes',
|
||||
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
|
||||
{ count: queued },
|
||||
)
|
||||
: null;
|
||||
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
||||
const label = t('offline.banner', 'You are offline');
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<CloudOff size={14} color={c.primaryForeground} />
|
||||
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||
<CloudOff size={14} color={c.warningForeground} />
|
||||
<Text style={styles.text} numberOfLines={1}>
|
||||
You are offline{suffix ? ` — ${suffix}` : ''}
|
||||
{suffix ? `${label} — ${suffix}` : label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -40,11 +47,11 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#a16207',
|
||||
backgroundColor: c.warning,
|
||||
},
|
||||
text: {
|
||||
...typography.caption,
|
||||
color: c.primaryForeground,
|
||||
color: c.warningForeground,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { BellRing, X } from 'lucide-react-native';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
dismissPushPrompt,
|
||||
getEffectiveRelayBaseUrl,
|
||||
isPushEnabledForAccount,
|
||||
isPushSupported,
|
||||
PushSetupError,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
wasPushPromptDismissed,
|
||||
} from '../lib/push-notifications';
|
||||
|
||||
// Mirrors the webmail's push-notification-prompt: a short delay after login
|
||||
// so the inbox renders first, and never again for an account once dismissed.
|
||||
const PROMPT_DELAY_MS = 1500;
|
||||
|
||||
/**
|
||||
* Once-per-account invitation to turn on background notifications. Shown
|
||||
* only where push can actually work (Android with Play services), when the
|
||||
* user has not disabled mail notifications, and until either "Enable" or
|
||||
* "Not now" has been tapped for the active account.
|
||||
*/
|
||||
export function PushOnboardingPrompt(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const emailNotificationsEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisible(false);
|
||||
setError(null);
|
||||
if (!isAuthenticated || !client || !activeAccountId || !emailNotificationsEnabled) return;
|
||||
if (!isPushSupported()) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
if (await wasPushPromptDismissed(activeAccountId)) return;
|
||||
if (await isPushEnabledForAccount(activeAccountId)) return;
|
||||
if (!cancelled) setVisible(true);
|
||||
})();
|
||||
}, PROMPT_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isAuthenticated, client, activeAccountId, emailNotificationsEnabled]);
|
||||
|
||||
if (!visible || !activeAccountId) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false);
|
||||
void dismissPushPrompt(activeAccountId);
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const relayBaseUrl = (await getEffectiveRelayBaseUrl()) || DEFAULT_RELAY_BASE_URL;
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
await setupPushNotifications({ relayBaseUrl, accountLabel: username ?? undefined });
|
||||
await dismissPushPrompt(activeAccountId);
|
||||
setVisible(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof PushSetupError || err instanceof Error
|
||||
? err.message
|
||||
: t('settings.notifications.push.setup_failed', 'Setup failed');
|
||||
setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card} accessibilityRole="summary">
|
||||
<View style={styles.iconWrap}>
|
||||
<BellRing size={18} color={c.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>
|
||||
{t('push_prompt.title', 'Get notified about new mail')}
|
||||
</Text>
|
||||
<Text style={styles.body}>
|
||||
{t(
|
||||
'push_prompt.body',
|
||||
'Turn on background notifications so new messages reach you while the app is closed.',
|
||||
)}
|
||||
</Text>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" onPress={() => void handleEnable()} loading={busy}>
|
||||
{t('push_prompt.enable', 'Enable')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={handleDismiss} disabled={busy}>
|
||||
{t('push_prompt.dismiss', 'Not now')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleDismiss}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
|
||||
style={styles.close}
|
||||
>
|
||||
<X size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.card,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: c.text },
|
||||
body: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
error: { ...typography.caption, color: c.error, marginTop: spacing.xs },
|
||||
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
||||
close: { padding: 2 },
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
@@ -17,11 +18,15 @@ interface QrScanModalProps {
|
||||
|
||||
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const theme = useResolvedTheme();
|
||||
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
|
||||
const t = useLocaleStore((st) => st.t);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// Guards against the camera firing onBarcodeScanned dozens of times for the
|
||||
// same code before the modal tears down.
|
||||
const handled = React.useRef(false);
|
||||
// Once iOS/Android stops letting us re-prompt, the only path left is Settings.
|
||||
const blocked = !!permission && !permission.granted && !permission.canAskAgain;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) handled.current = false;
|
||||
@@ -48,41 +53,60 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{permission?.granted ? (
|
||||
// Dims everything except the scan square. Four scrim regions leave a
|
||||
// transparent hole so the camera shows through only inside the frame.
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<View style={styles.scrim} />
|
||||
<View style={styles.scrimCenterRow}>
|
||||
<View style={styles.scrimSide} />
|
||||
<View style={styles.frame} />
|
||||
<View style={styles.scrimSide} />
|
||||
</View>
|
||||
<View style={[styles.scrim, styles.scrimBottom]}>
|
||||
<View style={styles.hintWrap}>
|
||||
<Text style={styles.hintTitle}>{t('login.mobile.qr_hint_title', 'Open Bulwark on your computer')}</Text>
|
||||
<Text style={styles.hint}>
|
||||
{t('login.mobile.qr_hint', 'Settings → Security → Link device shows a code. Point the camera at it.')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color="#ffffff" />
|
||||
<X size={24} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Scan QR code</Text>
|
||||
<Text style={styles.title}>{t('login.mobile.qr_title', 'Sign-in code')}</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{permission?.granted ? (
|
||||
<View style={styles.frameWrap}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Point your camera at the QR code shown on the webmail settings screen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
{!permission?.granted ? (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{permission && !permission.canAskAgain
|
||||
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
|
||||
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
|
||||
{blocked
|
||||
? t('login.mobile.camera_disabled', 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.')
|
||||
: t('login.mobile.camera_needed', 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.')}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
Allow camera access
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
onPress={() => void (blocked ? Linking.openSettings() : requestPermission())}
|
||||
>
|
||||
{blocked ? t('login.mobile.open_settings', 'Open Settings') : t('login.mobile.continue', 'Continue')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
function makeStyles(c: ThemePalette, theme: 'light' | 'dark') {
|
||||
const scrimColor = theme === 'light' ? 'rgba(248,250,252,0.72)' : 'rgba(0,0,0,0.6)';
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
overlay: { flex: 1, justifyContent: 'flex-start' },
|
||||
@@ -95,21 +119,25 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { ...typography.h3, color: '#ffffff' },
|
||||
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
|
||||
title: { ...typography.h3, color: c.text },
|
||||
scrim: { flex: 1, backgroundColor: scrimColor },
|
||||
scrimCenterRow: { flexDirection: 'row', height: 240 },
|
||||
scrimSide: { flex: 1, backgroundColor: scrimColor },
|
||||
scrimBottom: { alignItems: 'center', paddingTop: spacing.xl },
|
||||
frame: {
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
borderColor: c.text,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
|
||||
hintTitle: { ...typography.bodySemibold, color: c.text, textAlign: 'center' },
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: '#ffffff',
|
||||
color: c.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={[styles.container, style]} accessibilityRole="radiogroup">
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
@@ -34,6 +34,9 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
||||
key={opt.value}
|
||||
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
|
||||
onPress={() => onChange(opt.value)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected, checked: selected }}
|
||||
accessibilityLabel={opt.label}
|
||||
>
|
||||
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
|
||||
{opt.label}
|
||||
|
||||
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
@@ -24,7 +21,9 @@ export type RichTextCommand =
|
||||
| 'justifyRight'
|
||||
| 'removeFormat'
|
||||
| 'undo'
|
||||
| 'redo';
|
||||
| 'redo'
|
||||
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
|
||||
| `foreColor:${string}`;
|
||||
|
||||
export interface RichTextSelectionState {
|
||||
bold: boolean;
|
||||
@@ -53,7 +52,16 @@ export interface RichTextEditorHandle {
|
||||
insertLink(url: string, label?: string): void;
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||
insertHtml(html: string): void;
|
||||
setHtml(html: string): void;
|
||||
/**
|
||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||
* rejects after `timeoutMs` when the page script is dead or the bridge is
|
||||
* broken — callers must treat that as "content unknown", never fall back to
|
||||
* possibly-stale state (issue #9: doing so silently sent blank replies).
|
||||
*/
|
||||
getHtml(timeoutMs?: number): Promise<string>;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
@@ -66,273 +74,6 @@ interface Props {
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient.
|
||||
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||
var trimmed = (url || '').trim();
|
||||
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
@@ -340,7 +81,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
@@ -360,13 +101,46 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
[],
|
||||
);
|
||||
|
||||
// Scripts injected before the page has run its inline script are lost, and
|
||||
// the composer talks to the editor as soon as async data arrives (signature
|
||||
// after the identities load, hydrated inline images). Queue until the page
|
||||
// posts its first message, then replay in order.
|
||||
const readyRef = React.useRef(false);
|
||||
const queuedRef = React.useRef<string[]>([]);
|
||||
const call = React.useCallback((script: string) => {
|
||||
if (!readyRef.current) {
|
||||
queuedRef.current.push(script);
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
// Pending getHtml() readbacks, keyed by request id. Timers reject requests
|
||||
// the page never answers (dead script / broken bridge).
|
||||
const snapshotSeq = React.useRef(0);
|
||||
const pendingSnapshots = React.useRef(
|
||||
new Map<number, {
|
||||
resolve: (html: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>(),
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const pending = pendingSnapshots.current;
|
||||
return () => {
|
||||
for (const { timer, reject } of pending.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Editor unmounted'));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
const sep = command.indexOf(':');
|
||||
const name = sep === -1 ? command : command.slice(0, sep);
|
||||
const value = sep === -1 ? undefined : command.slice(sep + 1);
|
||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||
},
|
||||
@@ -379,9 +153,22 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
insertImage: (src, cid, alt) => {
|
||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||
},
|
||||
insertHtml: (html) => {
|
||||
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
|
||||
},
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
getHtml: (timeoutMs = 2000) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = ++snapshotSeq.current;
|
||||
const timer = setTimeout(() => {
|
||||
pendingSnapshots.current.delete(id);
|
||||
reject(new Error('Editor did not answer'));
|
||||
}, timeoutMs);
|
||||
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||
}),
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
@@ -394,13 +181,29 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!readyRef.current) {
|
||||
readyRef.current = true;
|
||||
const queued = queuedRef.current;
|
||||
queuedRef.current = [];
|
||||
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}
|
||||
switch (data.type) {
|
||||
case 'change':
|
||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'htmlSnapshot': {
|
||||
const p = data.payload as { id?: number; html?: string } | null;
|
||||
const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined;
|
||||
if (entry) {
|
||||
pendingSnapshots.current.delete(p!.id!);
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(typeof p?.html === 'string' ? p.html : '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
|
||||
@@ -15,13 +15,19 @@ interface SenderAvatarProps {
|
||||
email?: string | null;
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Never load a remote image (favicon) — initials only. The list passes this
|
||||
* inside Junk unless `showAvatarsInJunk` is on (webmail 1.5.1), so a spam
|
||||
* sender's domain is not contacted by merely listing the folder.
|
||||
*/
|
||||
disableImages?: boolean;
|
||||
}
|
||||
|
||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||
// company favicon (if sender domain is non-personal and `senderFavicons` is on)
|
||||
// → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style }: SenderAvatarProps) {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+107
-98
@@ -3,12 +3,18 @@ import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
|
||||
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
|
||||
import {
|
||||
shouldClaimGesture, dragOffset, resolveRelease, exitsRow,
|
||||
COMMIT_THRESHOLD, REVEAL_WIDTH,
|
||||
type SwipeConfig,
|
||||
} from './swipe-gesture';
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
@@ -17,7 +23,7 @@ interface SwipeableRowProps {
|
||||
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
|
||||
rightAction: SwipeAction;
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean; pinned: boolean };
|
||||
context: SwipeContext;
|
||||
onAction: (action: SwipeAction) => void;
|
||||
/**
|
||||
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
|
||||
@@ -26,17 +32,14 @@ interface SwipeableRowProps {
|
||||
mode?: SwipeMode;
|
||||
}
|
||||
|
||||
// Instant mode: distance the user must drag past on release to fire the action.
|
||||
const COMMIT_THRESHOLD = 96;
|
||||
// Reveal mode: drag past this on release to snap to the open (revealed) state.
|
||||
const ACTIVATION_THRESHOLD = 32;
|
||||
// Reveal mode: width of the action band shown when revealed.
|
||||
const REVEAL_WIDTH = 88;
|
||||
export interface SwipeContext {
|
||||
unread: boolean;
|
||||
starred: boolean;
|
||||
pinned: boolean;
|
||||
/** Inside Junk the spam band flips to "Not spam" (webmail 1.7.7). */
|
||||
inJunk?: boolean;
|
||||
}
|
||||
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
|
||||
// Distance the row flies off-screen by before the action callback fires.
|
||||
const EXIT_DISTANCE = 600;
|
||||
|
||||
@@ -50,18 +53,26 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
|
||||
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
|
||||
};
|
||||
|
||||
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
|
||||
if (action === 'read') return context.unread ? 'Read' : 'Unread';
|
||||
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
|
||||
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
|
||||
if (action === 'none') return '';
|
||||
return ACTION_META[action].defaultLabel;
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate): string {
|
||||
switch (action) {
|
||||
case 'none': return '';
|
||||
case 'read': return context.unread ? t('context_menu.mark_read', 'Read') : t('context_menu.mark_unread', 'Unread');
|
||||
case 'star': return context.starred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star');
|
||||
case 'pin': return context.pinned ? t('context_menu.unpin', 'Unpin') : t('context_menu.pin', 'Pin');
|
||||
case 'spam': return context.inJunk ? t('context_menu.not_spam', 'Not spam') : t('email_list.swipe_spam', 'Spam');
|
||||
case 'archive': return t('context_menu.archive', 'Archive');
|
||||
case 'delete': return t('context_menu.delete', 'Delete');
|
||||
case 'move': return t('email_list.swipe_move', 'Move');
|
||||
}
|
||||
}
|
||||
|
||||
// Actions that visibly remove the row from the list (so the row should fly off
|
||||
// instead of snapping back). Toggle-style actions stay in place.
|
||||
function exitsRow(action: SwipeAction): boolean {
|
||||
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
|
||||
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
|
||||
if (action === 'spam' && context.inJunk) return ShieldCheck;
|
||||
if (action === 'read' && !context.unread) return Mail;
|
||||
if (action === 'pin' && context.pinned) return PinOff;
|
||||
return ACTION_META[action].icon;
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
@@ -69,23 +80,42 @@ export function SwipeableRow({
|
||||
}: SwipeableRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const dx = useRef(new Animated.Value(0)).current;
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
// The PanResponder below is built once, so its handlers close over the props
|
||||
// of the render that built it. Reading them through a ref instead keeps the
|
||||
// gesture in step with the row it is actually attached to. Three ways that
|
||||
// went wrong before: changing a swipe action in settings kept firing the old
|
||||
// one; entering selection mode (which passes 'none') still fired actions;
|
||||
// and `onAction` captured a `handleSwipeAction` from before the mailbox ids
|
||||
// had loaded, so on a slow connection swipe did nothing even untouched.
|
||||
// Same idiom as RichTextEditor's callback refs.
|
||||
const latest = useRef<SwipeConfig & { onAction: (a: SwipeAction) => void }>({
|
||||
leftAction, rightAction, mode, onAction,
|
||||
});
|
||||
latest.current = { leftAction, rightAction, mode, onAction };
|
||||
|
||||
// Reveal-mode-only state: which side (if any) is currently sitting open.
|
||||
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
|
||||
const springTo = (toValue: number) => {
|
||||
Animated.spring(dx, { toValue, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
};
|
||||
// Spring back to rest without changing which side is open.
|
||||
const settle = () => springTo(0);
|
||||
|
||||
const close = () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
springTo(0);
|
||||
openSideRef.current = null;
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openTo = (side: 'left' | 'right') => {
|
||||
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
|
||||
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
springTo(side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH);
|
||||
openSideRef.current = side;
|
||||
setOpenSide(side);
|
||||
};
|
||||
@@ -101,14 +131,14 @@ export function SwipeableRow({
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
latest.current.onAction(action);
|
||||
// The row is about to be removed; reset translation in case it isn't
|
||||
// (e.g. the action failed silently) so we don't leave it off-screen.
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
latest.current.onAction(action);
|
||||
settle();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,77 +147,56 @@ export function SwipeableRow({
|
||||
if (action === 'none') return;
|
||||
if (exitsRow(action)) {
|
||||
// Let the row collapse a frame, then fire so the parent's list update
|
||||
// has a clean starting point.
|
||||
requestAnimationFrame(() => onAction(action));
|
||||
// has a clean starting point. Deferred, so read the callback on arrival
|
||||
// rather than capturing today's.
|
||||
requestAnimationFrame(() => latest.current.onAction(action));
|
||||
} else {
|
||||
onAction(action);
|
||||
latest.current.onAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
const responder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
// Reveal mode: if a side is already open, allow the gesture so the
|
||||
// user can drag it closed.
|
||||
if (mode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
} else if (mode !== 'reveal') {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base =
|
||||
mode === 'reveal'
|
||||
? openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0
|
||||
: 0;
|
||||
const next = base + g.dx;
|
||||
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
if (mode === 'reveal') {
|
||||
const base =
|
||||
openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0;
|
||||
const finalDx = base + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openTo('right');
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openTo('left');
|
||||
} else {
|
||||
close();
|
||||
// Built once. Every handler reads `latest.current`, so a rebuild would be a
|
||||
// no-op anyway — which is what makes useMemo (free to discard its cache)
|
||||
// safe here.
|
||||
const responder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (!shouldClaimGesture(g, latest.current, openSideRef.current)) return false;
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
dx.setValue(dragOffset(g, latest.current, openSideRef.current));
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
const outcome = resolveRelease(g, latest.current, openSideRef.current);
|
||||
switch (outcome.kind) {
|
||||
case 'open':
|
||||
openTo(outcome.side);
|
||||
break;
|
||||
case 'close':
|
||||
close();
|
||||
break;
|
||||
case 'fire':
|
||||
fly(outcome.direction * (widthRef.current || EXIT_DISTANCE), outcome.action);
|
||||
break;
|
||||
case 'settle':
|
||||
settle();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (mode === 'reveal') close();
|
||||
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (latest.current.mode === 'reveal') close();
|
||||
else settle();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
@@ -201,8 +210,8 @@ export function SwipeableRow({
|
||||
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
const Icon = actionIcon(action, context);
|
||||
const label = actionLabel(action, context, t);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => fireFromBandTap(action)}
|
||||
@@ -243,8 +252,8 @@ export function SwipeableRow({
|
||||
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
const Icon = actionIcon(action, context);
|
||||
const label = actionLabel(action, context, t);
|
||||
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
|
||||
const iconScale = dx.interpolate({
|
||||
inputRange,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user