Compare commits

..
22 Commits
Author SHA1 Message Date
Linus Rath 428a1bf03d chore: update version number to 0.1.57 2026-08-02 20:54:30 +02:00
Linus Rath 09350dd1f0 refactor: polish sign-in flow UI and theme-aware QR scanner 2026-08-02 20:54:21 +02:00
Linus Rath d2d13a37c9 fix: only say "Found your mail server" when discovery found it 2026-08-02 20:17:57 +02:00
Linus Rath be82bfe814 feat: show shared mailboxes (group accounts) in the sidebar #37 2026-08-02 20:17:29 +02:00
Linus Rath 5c775ad82e Merge pull request #31 from arnauda-gh/fix/swipe-action-desync
fix(ui): synchronize swipe action gesture handlers with latest props
2026-08-02 16:29:07 +02:00
Linus Rath 941e5ede12 refactor(ui): move swipe gesture decisions into a pure module
Keeps the fix — gesture handlers now read current props — but makes the
class of bug structurally impossible rather than patched case by case.

The PanResponder is created once, so its handlers closed over the props of
the render that built it. Four call sites had to be corrected individually,
and nothing stopped a fifth from being added stale. Now the decisions live
in swipe-gesture.ts as pure functions taking the config as an argument, so
there is nothing left for a handler to capture; the component keeps one
`latest` ref pointing at the current props and hands it over on each event.

Also:
- Replace the placeholder test, which asserted that assigning to a locally
  declared `{current}` object works and passed against the unfixed
  component, with 20 tests over the real gesture logic: claim/decline,
  overshoot clamping, commit thresholds, flick velocity, reveal open/close
  from an offset base, and the 'none' cases.
- Build the responder in a useMemo instead of re-running PanResponder.create
  on every render of every visible row and discarding the result.
- Fold the four repeated Animated.spring calls into springTo/settle.

Beyond the reported symptom, the same staleness broke two more cases:
entering selection mode passes 'none' for both actions, but a swipe still
fired the old action; and onAction captured a handleSwipeAction from before
the mailbox ids resolved, so on a slow connection swipe silently did nothing
without anyone touching the setting — which matches the deeper problem
Arnaud suspected on the PR.
2026-08-02 16:28:48 +02:00
Linus Rath 2577f4e64b Merge pull request #33 from arnauda-gh/fix/attachment-direct-view
fix(android): open attachments directly with default viewer app
2026-08-02 15:37:03 +02:00
Linus Rath ee21c15fec refactor(android): tidy the attachment direct-view handoff
Follow-up to the direct-view fix. No behaviour change on the happy path,
but the handoff is now easier to reason about and covered by tests.

- Use `File.contentUri` from the expo-file-system 19 API instead of the
  legacy `getContentUriAsync`, dropping the `expo-file-system/legacy`
  import. Same FileProvider, one less round trip through the bridge.
- Pull the intent launch into `openWithViewer`, which returns false when
  the handoff didn't happen, so the fallback reads as a decision rather
  than as an empty catch. It also no longer swallows the reason silently.
- Rewrite the expo-sharing comment above `shareAttachment`: it warns
  against pre-translating to a content URI, which is still true for every
  `Sharing.shareAsync` call but is exactly what the new VIEW path has to
  do. Note why `downloadAttachment` keeps the share sheet as well.
- Record why FLAG_ACTIVITY_NEW_TASK is deliberately absent: the module
  launches via startActivityForResult, which a new task would cancel.
2026-08-02 15:36:31 +02:00
Linus Rath e574fd9e11 chore: update version number to 0.1.56 2026-08-01 14:25:31 +02:00
Linus Rath dfa278f7a8 fix: sign iOS archives manually with an App Store provisioning profile 2026-08-01 13:47:49 +02:00
Linus Rath 4eeda34d95 fix: sign iOS archives with the distribution identity 2026-08-01 13:37:34 +02:00
Linus Rath 22ad291202 Merge branch 'main' of https://github.com/bulwarkmail/native 2026-08-01 13:29:18 +02:00
Linus Rath f040a48e2f feat: iOS TestFlight release pipeline 2026-08-01 13:29:15 +02:00
Linus Rath c29cbcd549 feat: step-by-step sign-in with server discovery from the email address 2026-08-01 13:28:31 +02:00
Linus Rath bf67b97176 Merge pull request #36 from lucletoffe/fix/unread-dot-anchor
fix(list): anchor the unread dot on the first line, not the row midpoint
2026-07-31 21:15:30 +02:00
lucletoffeandClaude Opus 5 a2484f3508 fix(list): anchor the unread dot on the first line, not the row midpoint
The dot added in #27 is positioned with `top: '50%'`, so it centres on the
whole row. Rows are three lines tall (sender / subject / preview), so the dot
lands a full line below the checkbox and the avatar it reads as a column
with — the taller the row, the worse it looks.

Anchor it on the first line instead: row padding + half an avatar, following
the density (and the text line height when the avatar is hidden in
extra-compact). The webmail's list carries the same `top-1/2` and the same
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
Linus Rath 82997e27e4 docs: fix doubled slash in logo URLs 2026-07-25 18:23:17 +02:00
arnauda-gh b7995fafde fix(android): open attachments directly with default viewer app
Fixes #32.

On Android, Sharing.shareAsync triggers Intent.ACTION_SEND (Share sheet). Updated shareAttachment to convert the file URI to a content URI and launch android.intent.action.VIEW via expo-intent-launcher, with fallback to shareAsync if no viewer application is installed for the MIME type.
2026-07-24 12:03:28 +02:00
arnauda-gh cde461999f fix(ui): synchronize swipe action gesture handlers with latest props
Fixes #30.

PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props.
2026-07-24 11:52:07 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
Linus Rath 2427bdbd79 fix: keep VTODO-only task lists out of the Calendars drawer #28
Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
2026-07-22 21:37:27 +02:00
Linus Rath 05e2af23dd fix: add unread indicator dot to email list and unified inbox rows #27
The only unread cues were a font-weight bump and a grey-to-white shift
on the sender name, which barely render on some device fonts and are
nearly invisible in the dark theme. Mirror the webmail's dedicated
indicator (email-list-item.tsx): an 8px circle in the theme's existing
unread color, vertically centered in the row's start gutter, shown in
both the mailbox list and the unified inbox.

Verified on a Pixel 7 API 34 emulator in dark theme: unread rows are
unambiguous at a glance, read rows unchanged.
2026-07-21 20:17:41 +02:00
64 changed files with 5704 additions and 684 deletions
+274
View File
@@ -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."
+6 -5
View File
@@ -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
- iOS builds, but push notifications and client certificates are Android-only so far
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- 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
+1 -1
View File
@@ -1 +1 @@
0.1.54
0.1.57
+27
View File
@@ -16,6 +16,12 @@ 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',
@@ -33,6 +39,14 @@ module.exports = {
},
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: {
@@ -55,6 +69,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,
},
],
],
+159
View File
@@ -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.
+1
View File
@@ -497,6 +497,7 @@
"drop_files": "Drop files to attach",
"show_less": "Show less",
"send_failed": "Failed to send email",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"continue_draft": "Continue draft",
"close_draft_title": "Save or discard draft?",
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
+953
View File
@@ -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&nbsp;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>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.57",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.57",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+93 -3
View File
@@ -87,7 +87,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 +102,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 +159,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 +205,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', () => {
+99 -1
View File
@@ -5,12 +5,16 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getAccountName: vi.fn(() => 'me@example.com'),
getSharedMailAccounts: vi.fn(() => []),
getMaxCallsInRequest: vi.fn(() => 16),
},
}));
import { jmapClient } from '../jmap-client';
import {
getMailboxes,
getSharedMailboxes,
queryEmails,
getEmails,
getFullEmail,
@@ -41,13 +45,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({
+6 -2
View File
@@ -67,13 +67,17 @@ export async function uploadBlob(
export async function uploadBytes(
bytes: Uint8Array,
type: string,
// 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<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const targetAccountId = accountId ?? jmapClient.accountId;
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
encodeURIComponent(targetAccountId),
);
const response = await secureFetch(uploadUrl, {
@@ -99,7 +103,7 @@ export async function uploadBytes(
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
const nested = raw[targetAccountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
+93 -10
View File
@@ -37,18 +37,94 @@ 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');
@@ -149,7 +225,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;
}
@@ -176,11 +252,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,
);
@@ -193,7 +269,9 @@ 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.
@@ -206,7 +284,9 @@ export async function batchCreateEvents(
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']],
@@ -223,8 +303,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);
@@ -285,7 +367,8 @@ 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);
}
export async function createCalendar(
+146 -44
View File
@@ -14,33 +14,112 @@ const EMAIL_FULL_PROPERTIES = [
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
];
export async function getMailboxes(): Promise<Mailbox[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
return res.methodResponses[0][1].list;
// Prefix a shared account's folder ids so they can't collide with the user's
// own. Matches the webmail's `${accountId}:${mailboxId}` scheme.
function sharedMailboxId(accountId: string, rawId: string): string {
return `${accountId}:${rawId}`;
}
/** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */
export function unprefixMailboxId(id: string, accountId?: string): string {
if (!accountId) return id;
const prefix = `${accountId}:`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
// Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw
// ids; a shared account's are prefixed and remember their `originalId` so
// server calls can still address them.
function tagMailbox(
mailbox: Mailbox,
accountId: string,
accountName: string | undefined,
isShared: boolean,
): Mailbox {
if (!isShared) {
return { ...mailbox, accountId, accountName, isShared: false };
}
return {
...mailbox,
id: sharedMailboxId(accountId, mailbox.id),
originalId: mailbox.id,
parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId,
accountId,
accountName,
isShared: true,
};
}
export async function getMailboxes(accountId?: string): Promise<Mailbox[]> {
return (await getMailboxesWithState(accountId)).list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
export async function getMailboxesWithState(
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
);
return { list, state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
/**
* Every folder of every shared/group account in this session, ready to merge
* into the sidebar next to the user's own. One request carrying a Mailbox/get
* per account, chunked to the server's maxCallsInRequest. An account that
* errors (revoked access, server hiccup) is skipped rather than sinking the
* whole list.
*/
export async function getSharedMailboxes(): Promise<Mailbox[]> {
const accounts = jmapClient.getSharedMailAccounts();
if (accounts.length === 0) return [];
const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest());
const out: Mailbox[] = [];
for (let i = 0; i < accounts.length; i += perRequest) {
const chunk = accounts.slice(i, i + perRequest);
const res = await jmapClient.request(
chunk.map((acc, idx): JMAPMethodCall => [
'Mailbox/get',
{ accountId: acc.id },
String(idx),
]),
);
for (const [name, body, callId] of res.methodResponses) {
if (name !== 'Mailbox/get') continue;
const acc = chunk[Number(callId)];
if (!acc) continue;
for (const m of (body.list as Mailbox[]) ?? []) {
out.push(tagMailbox(m, acc.id, acc.name, true));
}
}
}
return out;
}
export async function getMailboxesByIds(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
);
return { list, state: body.state as string };
}
export interface MailboxChangesResult {
@@ -54,8 +133,11 @@ export interface MailboxChangesResult {
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
export async function getMailboxChanges(
sinceState: string,
accountIdOverride?: string,
): Promise<MailboxChangesResult | null> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
@@ -73,8 +155,9 @@ export async function getMailboxChanges(sinceState: string): Promise<MailboxChan
export async function createMailbox(
data: { name: string; parentId?: string | null },
accountIdOverride?: string,
): Promise<string> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const cid = 'new-mailbox';
const res = await jmapClient.request([
['Mailbox/set', {
@@ -102,8 +185,9 @@ export async function createMailbox(
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null },
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
@@ -117,8 +201,8 @@ export async function updateMailbox(
}
}
export async function deleteMailbox(id: string): Promise<void> {
const accountId = jmapClient.accountId;
export async function deleteMailbox(id: string, accountIdOverride?: string): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, destroy: [id] }, '0'],
]);
@@ -155,9 +239,11 @@ export async function queryEmails(
limit?: number;
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
},
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const accountId = jmapClient.accountId;
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
@@ -196,9 +282,11 @@ export async function getEmailQueryChanges(
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = jmapClient.accountId;
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
@@ -222,8 +310,8 @@ export async function getEmailQueryChanges(
};
}
export async function getEmails(ids: string[]): Promise<Email[]> {
const accountId = jmapClient.accountId;
export async function getEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
@@ -233,18 +321,21 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list.
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
export async function getEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Email[]; state: string }> {
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: [], state: body.state as string };
}
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
@@ -266,8 +357,9 @@ export interface EmailChangesResult {
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
accountIdOverride?: string,
): Promise<EmailChangesResult | null> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
@@ -306,9 +398,9 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom
// Batch variant for offline sync. JMAP servers cap how many objects can be
// returned in a single Email/get; the caller should chunk to that ceiling
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
export async function getFullEmails(ids: string[]): Promise<Email[]> {
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
@@ -332,8 +424,9 @@ export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
accountIdOverride?: string,
): Promise<string> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
@@ -352,8 +445,8 @@ export async function importEmailBlob(
return id;
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
export async function getThread(threadId: string, accountIdOverride?: string): Promise<Thread> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Thread/get', { accountId, ids: [threadId] }, '0']],
);
@@ -375,8 +468,9 @@ export async function moveEmail(
emailId: string,
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', {
accountId,
@@ -395,9 +489,10 @@ export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
@@ -413,22 +508,24 @@ export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId);
await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride);
}
}
// Apply keyword maps to several emails in one round-trip.
export async function setKeywordsForEmails(
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
accountIdOverride?: string,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords };
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
@@ -439,9 +536,10 @@ export async function setKeywordsForEmails(
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
accountIdOverride?: string,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
@@ -462,15 +560,16 @@ export async function restoreEmailMailboxes(
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay.
export async function destroyEmails(ids: string[]): Promise<void> {
export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
}
@@ -482,9 +581,10 @@ export async function archiveEmails(
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountIdOverride?: string,
): Promise<void> {
if (emails.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
@@ -595,14 +695,15 @@ export async function deleteEmail(
emailId: string,
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, destroy: [emailId] }, '0'],
]);
} else {
await moveEmail(emailId, currentMailboxId, trashMailboxId);
await moveEmail(emailId, currentMailboxId, trashMailboxId, accountIdOverride);
}
}
@@ -610,8 +711,9 @@ export async function searchEmails(
query: string,
mailboxId?: string,
limit = 30,
accountIdOverride?: string,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
if (mailboxId) filter.inMailbox = mailboxId;
+48 -4
View File
@@ -459,6 +459,43 @@ export class JMAPClient {
return core?.maxObjectsInGet || 500;
}
getMaxCallsInRequest(): number {
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
| { maxCallsInRequest?: number }
| undefined;
return core?.maxCallsInRequest || 16;
}
// ── 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;
}
// ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
@@ -515,19 +552,26 @@ 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);
async fetchBlobArrayBuffer(
blobId: string,
name?: string,
type?: string,
accountId?: string,
): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
+14
View File
@@ -99,7 +99,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 +118,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 {
+9 -3
View File
@@ -27,6 +27,8 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
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.
@@ -457,7 +459,9 @@ const PINCH_ZOOM = `
})();
`;
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange,
}: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
@@ -561,7 +565,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);
const buf = await jmapClient.fetchBlobArrayBuffer(
att.blobId, att.name, att.type, jmapAccountId,
);
if (cancelled) return;
const b64 = arrayBufferToBase64(buf);
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
@@ -575,7 +581,7 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
}
})();
return () => { cancelled = true; };
}, [rawHtml, email.attachments]);
}, [rawHtml, email.attachments, jmapAccountId]);
const source = React.useMemo(() => {
if (rawHtml) {
+3 -1
View File
@@ -98,7 +98,9 @@ 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.
const canTarget =
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
+39 -19
View File
@@ -4,7 +4,7 @@ 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';
interface QrScanModalProps {
@@ -17,7 +17,8 @@ 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 [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
@@ -48,23 +49,37 @@ 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}>Open Bulwark on your computer</Text>
<Text style={styles.hint}>
Settings → Devices → Add phone 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}>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
@@ -75,14 +90,15 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
Allow camera access
</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 +111,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,
+48
View File
@@ -51,6 +51,13 @@ export interface RichTextEditorHandle {
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: 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;
}
@@ -94,6 +101,27 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
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(':');
@@ -112,6 +140,16 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
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()`);
},
@@ -133,6 +171,16 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
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':
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
break;
+34 -7
View File
@@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers,
Clock, Layers, Users,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -83,6 +83,8 @@ interface SidebarRowProps {
label: string;
depth: number;
isSelected: boolean;
/** Header for a shared/group account: styled as a section, never selected. */
isAccountHeader?: boolean;
unread: number;
total: number;
hasChildren: boolean;
@@ -92,7 +94,7 @@ interface SidebarRowProps {
}
function SidebarRow({
icon, label, depth, isSelected, unread, total,
icon, label, depth, isSelected, isAccountHeader, unread, total,
hasChildren, isExpanded, onPress, onToggleExpand,
}: SidebarRowProps) {
const c = useColors();
@@ -126,7 +128,11 @@ function SidebarRow({
</View>
<View style={styles.rowIcon}>{icon}</View>
<Text
style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}
style={[
styles.rowLabel,
isSelected && styles.rowLabelSelected,
isAccountHeader && styles.rowLabelAccount,
]}
numberOfLines={1}
>
{label}
@@ -467,20 +473,34 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = node.id === currentMailboxId;
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.name}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={() => handleSelect(node.id)}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onToggleExpand={() => toggleExpand(node.id)}
/>
);
@@ -741,6 +761,13 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowLabelAccount: {
...typography.caption,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: c.textSecondary,
},
counts: {
flexDirection: 'row',
alignItems: 'baseline',
+74 -89
View File
@@ -9,6 +9,11 @@ import {
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
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;
@@ -26,17 +31,6 @@ 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;
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;
@@ -58,12 +52,6 @@ function actionLabel(action: SwipeAction, context: { unread: boolean; starred: b
return ACTION_META[action].defaultLabel;
}
// 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';
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
@@ -73,19 +61,37 @@ export function SwipeableRow({
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 +107,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 +123,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 || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
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;
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import {
shouldClaimGesture,
dragOffset,
resolveRelease,
restingOffset,
exitsRow,
COMMIT_THRESHOLD,
ACTIVATION_THRESHOLD,
REVEAL_WIDTH,
type SwipeConfig,
} from '../swipe-gesture';
const instant = (over: Partial<SwipeConfig> = {}): SwipeConfig => ({
mode: 'instant',
leftAction: 'delete',
rightAction: 'archive',
...over,
});
const reveal = (over: Partial<SwipeConfig> = {}): SwipeConfig => instant({ mode: 'reveal', ...over });
// PanResponder hands over dx/dy/vx; the rest of the gesture state is unused.
const g = (dx: number, opts: { dy?: number; vx?: number } = {}) => ({
dx,
dy: opts.dy ?? 0,
vx: opts.vx ?? 0,
});
describe('shouldClaimGesture', () => {
it('ignores drags too short to be intentional', () => {
expect(shouldClaimGesture(g(4), instant(), null)).toBe(false);
expect(shouldClaimGesture(g(-4), instant(), null)).toBe(false);
});
it('leaves mostly-vertical drags to the list scroll', () => {
expect(shouldClaimGesture(g(20, { dy: 40 }), instant(), null)).toBe(false);
expect(shouldClaimGesture(g(40, { dy: 20 }), instant(), null)).toBe(true);
});
it("declines the direction whose action is 'none'", () => {
const config = instant({ rightAction: 'none' });
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
expect(shouldClaimGesture(g(-50), config, null)).toBe(true);
});
it("declines both directions in selection mode, where both actions are 'none'", () => {
const config = instant({ leftAction: 'none', rightAction: 'none' });
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
});
it('claims any direction while a reveal band is open, so it can be dragged shut', () => {
const config = reveal({ leftAction: 'none', rightAction: 'archive' });
expect(shouldClaimGesture(g(-50), config, 'right')).toBe(true);
// ...but not once it is closed again.
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
});
});
describe('dragOffset', () => {
it('tracks the finger in instant mode', () => {
expect(dragOffset(g(70), instant(), null)).toBe(70);
expect(dragOffset(g(-70), instant(), null)).toBe(-70);
});
it('clamps to the overshoot limit', () => {
expect(dragOffset(g(9999), instant(), null)).toBe(240);
expect(dragOffset(g(-9999), instant(), null)).toBe(-240);
// Reveal mode allows much less overshoot than instant.
expect(dragOffset(g(9999), reveal(), null)).toBeCloseTo(REVEAL_WIDTH * 1.4);
});
it('offsets from the open position in reveal mode', () => {
// Already open to the right; dragging back left should approach 0.
expect(dragOffset(g(-REVEAL_WIDTH), reveal(), 'right')).toBe(0);
expect(dragOffset(g(-10), reveal(), 'right')).toBe(REVEAL_WIDTH - 10);
});
});
describe('resolveRelease — instant mode', () => {
it('fires the action for the direction dragged past the threshold', () => {
expect(resolveRelease(g(COMMIT_THRESHOLD), instant(), null)).toEqual({
kind: 'fire',
action: 'archive',
direction: 1,
});
expect(resolveRelease(g(-COMMIT_THRESHOLD), instant(), null)).toEqual({
kind: 'fire',
action: 'delete',
direction: -1,
});
});
it('accepts a short fast flick as a commit', () => {
expect(resolveRelease(g(50, { vx: 0.8 }), instant(), null)).toMatchObject({ kind: 'fire' });
// Same distance, too slow.
expect(resolveRelease(g(50, { vx: 0.2 }), instant(), null)).toEqual({ kind: 'settle' });
});
it('springs back when the drag stops short', () => {
expect(resolveRelease(g(COMMIT_THRESHOLD - 1), instant(), null)).toEqual({ kind: 'settle' });
});
it("never fires an action configured as 'none'", () => {
const config = instant({ rightAction: 'none' });
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
});
it('fires nothing in selection mode, however hard the row is flung', () => {
const config = instant({ leftAction: 'none', rightAction: 'none' });
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
expect(resolveRelease(g(-999, { vx: -5 }), config, null)).toEqual({ kind: 'settle' });
});
});
describe('resolveRelease — reveal mode', () => {
it('opens the band rather than firing', () => {
expect(resolveRelease(g(ACTIVATION_THRESHOLD + 1), reveal(), null)).toEqual({
kind: 'open',
side: 'right',
});
expect(resolveRelease(g(-ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({
kind: 'open',
side: 'left',
});
});
it('closes when the drag stops short', () => {
expect(resolveRelease(g(ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({ kind: 'close' });
});
it('measures from the open position, so a small drag keeps it open', () => {
// Open to the right, nudged 5px further right: still well past the threshold.
expect(resolveRelease(g(5), reveal(), 'right')).toEqual({ kind: 'open', side: 'right' });
// Dragged back past the resting point: closes.
expect(resolveRelease(g(-REVEAL_WIDTH), reveal(), 'right')).toEqual({ kind: 'close' });
});
it("closes instead of opening a side whose action is 'none'", () => {
const config = reveal({ rightAction: 'none' });
expect(resolveRelease(g(999), config, null)).toEqual({ kind: 'close' });
});
});
describe('restingOffset', () => {
it('maps the open side to the row translation it sits at', () => {
expect(restingOffset('right')).toBe(REVEAL_WIDTH);
expect(restingOffset('left')).toBe(-REVEAL_WIDTH);
expect(restingOffset(null)).toBe(0);
});
});
describe('exitsRow', () => {
it('flags the actions that remove the row from the list', () => {
expect((['archive', 'delete', 'spam', 'move'] as const).every(exitsRow)).toBe(true);
});
it('keeps toggle actions in place', () => {
expect((['read', 'star', 'pin', 'none'] as const).some(exitsRow)).toBe(false);
});
});
+5 -1
View File
@@ -12,6 +12,7 @@ import { useSettingsStore } from '../../stores/settings-store';
import { useFilterStore } from '../../stores/filter-store';
import { useVacationStore } from '../../stores/vacation-store';
import { useEmailStore } from '../../stores/email-store';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useLocaleStore } from '../../stores/locale-store';
import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
@@ -48,7 +49,10 @@ export function FilterSettings() {
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const mailboxes = useEmailStore((s) => s.mailboxes);
// Sieve rules run on the user's own account, so only own folders are
// valid "file into" targets.
const allMailboxes = useEmailStore((s) => s.mailboxes);
const mailboxes = useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
const vacationEnabled = useVacationStore((s) => s.isEnabled);
const {
+5 -1
View File
@@ -18,6 +18,7 @@ import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useEmailStore } from '../../stores/email-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import type { Mailbox } from '../../api/types';
@@ -43,7 +44,10 @@ type Editor =
export function FolderSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
// Folder management (create/rename/delete) targets the user's own account;
// shared (group account) folders are read-only here.
const allMailboxes = useEmailStore((s) => s.mailboxes);
const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState<Editor | null>(null);
+4 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
import { ownMailboxes } from '../../lib/mailbox-tree';
function MailLayoutPreview({ value }: { value: MailLayout }) {
const c = useColors();
@@ -103,7 +104,8 @@ export function ReadingSettings() {
const handleReorganizeArchive = async () => {
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
const archiveMailbox = mailboxes.find(
// Reorganising runs against the user's own archive only.
const archiveMailbox = ownMailboxes(mailboxes).find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) {
@@ -130,7 +132,7 @@ export function ReadingSettings() {
if (ids.length === 0) break;
const list = await getEmails(ids);
const refreshed = useEmailStore.getState().mailboxes;
const refreshed = ownMailboxes(useEmailStore.getState().mailboxes);
await archiveEmails(
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
+132
View File
@@ -0,0 +1,132 @@
// Pure decision logic behind SwipeableRow's pan gesture.
//
// This lives outside the component for a reason beyond tidiness: the row's
// PanResponder is created once and its handlers therefore close over whatever
// props existed at mount. Taking the config as an argument makes staleness
// structurally impossible here — there is nothing to capture — and leaves the
// component with one job, keeping the current config somewhere the handlers
// can reach.
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
// Instant mode: distance the user must drag past on release to fire the action.
export const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
export const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
export const REVEAL_WIDTH = 88;
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;
// A short, fast drag commits too, so the gesture doesn't demand the full
// COMMIT_THRESHOLD when the user clearly flicks.
const FLING_DX = 40;
const FLING_VX = 0.5;
/**
* The row's current swipe configuration.
*
* Note the naming: `rightAction` fires on a rightward (positive dx) drag and
* its band sits at the *left* edge; `leftAction` is the mirror image.
*/
export interface SwipeConfig {
leftAction: SwipeAction;
rightAction: SwipeAction;
mode: SwipeMode;
}
/** The subset of RN's PanResponder gesture state this logic needs. */
export interface SwipeGestureState {
dx: number;
dy: number;
vx: number;
}
/** Which side's action band is currently held open (reveal mode only). */
export type OpenSide = 'left' | 'right' | null;
export type ReleaseOutcome =
/** Reveal mode: snap open so the band can be tapped. */
| { kind: 'open'; side: 'left' | 'right' }
/** Reveal mode: snap shut. */
| { kind: 'close' }
/** Instant mode: run the action; `direction` is the sign to fly the row off by. */
| { kind: 'fire'; action: SwipeAction; direction: 1 | -1 }
/** Not far enough — spring back to rest without firing. */
| { kind: 'settle' };
/**
* Whether the pan should be claimed from whatever else might want it (the
* list's vertical scroll, mainly). Declining leaves the gesture to the parent.
*/
export function shouldClaimGesture(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): boolean {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// An already-open row claims any horizontal drag so the user can drag it
// shut, even in the direction whose action is 'none'.
if (config.mode === 'reveal' && openSide !== null) return true;
if (g.dx > 0 && config.rightAction === 'none') return false;
if (g.dx < 0 && config.leftAction === 'none') return false;
return true;
}
/** Row translation for an in-progress drag, clamped to the mode's overshoot. */
export function dragOffset(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): number {
const reveal = config.mode === 'reveal';
const overshoot = reveal ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base = reveal ? restingOffset(openSide) : 0;
return Math.max(-overshoot, Math.min(overshoot, base + g.dx));
}
/** Where the row sits at rest, given which side is open. */
export function restingOffset(openSide: OpenSide): number {
if (openSide === 'right') return REVEAL_WIDTH;
if (openSide === 'left') return -REVEAL_WIDTH;
return 0;
}
/** What releasing the drag here should do. */
export function resolveRelease(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): ReleaseOutcome {
if (config.mode === 'reveal') {
const finalDx = restingOffset(openSide) + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && config.rightAction !== 'none') {
return { kind: 'open', side: 'right' };
}
if (finalDx < -ACTIVATION_THRESHOLD && config.leftAction !== 'none') {
return { kind: 'open', side: 'left' };
}
return { kind: 'close' };
}
// Instant mode: fire on release-past-threshold, no second tap.
const committedRight = g.dx >= COMMIT_THRESHOLD || (g.dx > FLING_DX && g.vx >= FLING_VX);
if (committedRight && config.rightAction !== 'none') {
return { kind: 'fire', action: config.rightAction, direction: 1 };
}
const committedLeft = g.dx <= -COMMIT_THRESHOLD || (g.dx < -FLING_DX && g.vx <= -FLING_VX);
if (committedLeft && config.leftAction !== 'none') {
return { kind: 'fire', action: config.leftAction, direction: -1 };
}
return { kind: 'settle' };
}
/**
* 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.
*/
export function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
+8
View File
@@ -47,4 +47,12 @@ describe('buildEditorHtml', () => {
expect(script).toContain('window.__rne');
expect(script).toContain('ReactNativeWebView.postMessage');
});
// Regression for issue #9: sends must be able to read the live DOM back
// instead of trusting async `change` messages that can lag or be lost.
it('exposes the send-time getHtml readback on the bridge', () => {
const script = extractScript(buildEditorHtml(base));
expect(script).toContain('getHtml: function (id)');
expect(script).toContain("post('htmlSnapshot', { id: id, html: editor.innerHTML })");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Platform.OS is flipped per test, so hoist a mutable object into the module
// mock rather than using the fixed 'android' stub from test-setup.
const { platform } = vi.hoisted(() => ({ platform: { OS: 'android' as string, Version: 33 } }));
vi.mock('react-native', () => ({
Platform: platform,
NativeModules: {},
NativeEventEmitter: class {
addListener() {
return { remove: () => undefined };
}
},
}));
// A `File` that records both URL forms, so tests can assert which one each
// handoff path was given: expo-sharing needs `file://`, an Intent needs
// `content://`.
vi.mock('expo-file-system', () => {
class File {
name: string;
uri: string;
contentUri: string;
exists = true;
constructor(dir: { uri: string }, name: string) {
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
create() {}
delete() {}
write() {}
static downloadFileAsync = vi.fn(async () => undefined);
}
return {
File,
Directory: class {},
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
};
});
vi.mock('expo-intent-launcher', () => ({
startActivityAsync: vi.fn(async () => ({ resultCode: -1 })),
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(async () => undefined),
}));
vi.mock('../client-cert', () => ({
getClientCertAlias: vi.fn(async () => null),
secureFetch: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { authHeader: 'Bearer token' },
}));
vi.mock('../../api/blob', () => ({
getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`),
}));
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { shareAttachment, downloadAttachment } from '../email-export';
const VIEW = 'android.intent.action.VIEW';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
describe('shareAttachment (preview)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});
it('hands the file to a viewer app via a VIEW intent on Android', async () => {
await shareAttachment('blob-1', 'report.pdf', 'application/pdf');
expect(IntentLauncher.startActivityAsync).toHaveBeenCalledWith(VIEW, {
data: 'content://org.bulwarkmail.mobile.FileSystemFileProvider/report.pdf',
type: 'application/pdf',
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
// The share sheet is what the direct handoff exists to avoid.
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
it('falls back to the share sheet when no app handles the type', async () => {
vi.mocked(IntentLauncher.startActivityAsync).mockRejectedValue(
new Error('No Activity found to handle Intent'),
);
await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');
// file:// — expo-sharing rejects content:// URLs outright.
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/weird.xyz', {
mimeType: 'application/x-weird',
dialogTitle: 'weird.xyz',
});
});
it('surfaces a download failure instead of silently falling back', async () => {
const { File } = await import('expo-file-system');
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
await expect(shareAttachment('blob-3', 'gone.pdf', 'application/pdf')).rejects.toThrow(
'Download failed: 404',
);
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
});
it('uses the share sheet on iOS', async () => {
platform.OS = 'ios';
await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/photo.jpg', {
mimeType: 'image/jpeg',
dialogTitle: 'photo.jpg',
});
});
});
describe('downloadAttachment (save a copy)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});
it('keeps the share sheet on Android so the save dialog stays reachable', async () => {
await downloadAttachment('blob-5', 'report.pdf', 'application/pdf');
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { describeLoginError } from '../login-errors';
function named(name: string, message: string): Error {
const err = new Error(message);
err.name = name;
return err;
}
describe('describeLoginError', () => {
it('turns a rejected credential into an actionable message', () => {
const copy = describeLoginError(named('AuthenticationError', 'Invalid credentials'));
expect(copy.title).toBe("That didn't work");
expect(copy.detail).toMatch(/app password/i);
});
it('names the host it could not reach', () => {
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
serverUrl: 'https://mail.example.com',
});
expect(copy.title).toBe("Can't reach mail.example.com");
});
it('falls back to a generic host label when the server is unknown', () => {
const copy = describeLoginError(named('TypeError', 'Network request failed'));
expect(copy.title).toBe("Can't reach the server");
});
it('explains a 404 from session discovery as a wrong address', () => {
const copy = describeLoginError(new Error('Session discovery failed: 404 Not Found'), {
serverUrl: 'https://example.com',
});
expect(copy.title).toBe('No mail server at example.com');
});
it('flags a certificate problem separately from a connection problem', () => {
const copy = describeLoginError(new Error('SSL certificate has expired'), {
serverUrl: 'https://mail.example.com',
});
expect(copy.title).toBe("Couldn't verify mail.example.com");
});
it('explains an expired pairing code', () => {
const copy = describeLoginError(new Error('Pairing code is invalid or has expired'));
expect(copy.title).toBe('That code has expired');
expect(copy.detail).toMatch(/fresh one/i);
});
it('passes through an unrecognised message rather than inventing a cause', () => {
const copy = describeLoginError(new Error('Teapot refused to brew'));
expect(copy.title).toBe('Sign-in failed');
expect(copy.detail).toBe('Teapot refused to brew');
});
it('handles non-Error throws', () => {
expect(describeLoginError(undefined).title).toBe('Sign-in failed');
expect(describeLoginError('boom').detail).toBe('boom');
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import {
buildMailboxTree,
flattenVisible,
mailboxesForSiblingOf,
ownMailboxes,
SHARED_ACCOUNT_NODE_PREFIX,
} from '../mailbox-tree';
import type { Mailbox } from '../../api/types';
const RIGHTS: Mailbox['myRights'] = {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
};
function own(id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
return {
id,
name,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: RIGHTS,
accountId: 'acc-1',
isShared: false,
...extra,
};
}
function shared(
accountId: string,
accountName: string,
rawId: string,
name: string,
extra: Partial<Mailbox> = {},
): Mailbox {
return {
...own(`${accountId}:${rawId}`, name, extra),
originalId: rawId,
accountId,
accountName,
isShared: true,
};
}
describe('buildMailboxTree with shared accounts', () => {
it('keeps own folders at the root and groups each shared account under its own node', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
own('sent', 'Sent', { role: 'sent' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox', unreadEmails: 3 }),
shared('grp-1', 'Support', 'arch', 'Archive', { role: 'archive', unreadEmails: 1 }),
]);
expect(tree.map((n) => n.id)).toEqual([
'inbox',
'sent',
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
]);
const accountNode = tree[2];
expect(accountNode.isAccountNode).toBe(true);
expect(accountNode.name).toBe('Support');
// The header rolls up its account's unread so a collapsed section still
// signals waiting mail.
expect(accountNode.unreadEmails).toBe(4);
expect(accountNode.children.map((n) => n.id)).toEqual(['grp-1:inbox', 'grp-1:arch']);
expect(accountNode.children.every((n) => n.depth === 1)).toBe(true);
});
it('nests a shared account\'s subfolders under their own parent, not the user\'s', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'sub', 'Escalations', { parentId: 'grp-1:inbox' }),
]);
const accountNode = tree[1];
expect(accountNode.children).toHaveLength(1);
expect(accountNode.children[0].children.map((n) => n.name)).toEqual(['Escalations']);
expect(accountNode.children[0].children[0].depth).toBe(2);
});
it('gives each shared account its own node', () => {
const tree = buildMailboxTree([
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
]);
expect(tree.map((n) => n.name)).toEqual(['Sales', 'Support']);
});
it('hides a collapsed shared account\'s folders', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
]);
const collapsed = flattenVisible(tree, new Set());
expect(collapsed.map((n) => n.id)).toEqual(['inbox', `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]);
const expanded = flattenVisible(tree, new Set([`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]));
expect(expanded.map((n) => n.id)).toEqual([
'inbox',
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
'grp-1:inbox',
]);
});
});
describe('account scoping helpers', () => {
const all = [
own('inbox', 'Inbox', { role: 'inbox' }),
own('trash', 'Trash', { role: 'trash' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'trash', 'Trash', { role: 'trash' }),
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
];
it('ownMailboxes drops every shared folder', () => {
expect(ownMailboxes(all).map((m) => m.id)).toEqual(['inbox', 'trash']);
});
it('scopes to the owning shared account when the current folder is shared', () => {
expect(mailboxesForSiblingOf(all, 'grp-1:inbox').map((m) => m.id))
.toEqual(['grp-1:inbox', 'grp-1:trash']);
});
it('scopes to the user\'s own folders for an own or unknown folder', () => {
expect(mailboxesForSiblingOf(all, 'inbox').map((m) => m.id)).toEqual(['inbox', 'trash']);
expect(mailboxesForSiblingOf(all, 'gone').map((m) => m.id)).toEqual(['inbox', 'trash']);
expect(mailboxesForSiblingOf(all, null).map((m) => m.id)).toEqual(['inbox', 'trash']);
});
});
@@ -74,6 +74,54 @@ describe('expandRecurringEvents - weekly', () => {
});
});
describe('expandRecurringEvents - interval > 1 (#13)', () => {
it('expands an every-5-weeks rule', () => {
const rule: RecurrenceRule = { frequency: 'weekly', interval: 5 };
const result = expandRecurringEvents(
[ev({ recurrenceRules: [rule] })], // starts Mon 2026-04-06
'2026-04-01T00:00:00',
'2026-08-01T00:00:00',
);
expect(result.map((e) => e.start)).toEqual([
'2026-04-06T09:00:00',
'2026-05-11T09:00:00',
'2026-06-15T09:00:00',
'2026-07-20T09:00:00',
]);
});
it('expands an every-5-weeks series started long before the visible range', () => {
const event = ev({
start: '2025-01-06T09:00:00', // Monday
recurrenceRules: [{ frequency: 'weekly', interval: 5 }],
});
// Occurrences fall every 35 days from 2025-01-06 (…2026-04-06, 05-11,
// 06-15, 07-20…) — only those inside the window are returned.
const result = expandRecurringEvents([event], '2026-05-01T00:00:00', '2026-08-01T00:00:00');
expect(result.map((e) => e.start)).toEqual([
'2026-05-11T09:00:00',
'2026-06-15T09:00:00',
'2026-07-20T09:00:00',
]);
});
it('expands an every-2-months rule', () => {
const rule: RecurrenceRule = { frequency: 'monthly', interval: 2 };
const result = expandRecurringEvents(
[ev({ recurrenceRules: [rule] })],
'2026-04-01T00:00:00',
'2026-12-31T00:00:00',
);
expect(result.map((e) => e.start)).toEqual([
'2026-04-06T09:00:00',
'2026-06-06T09:00:00',
'2026-08-06T09:00:00',
'2026-10-06T09:00:00',
'2026-12-06T09:00:00',
]);
});
});
describe('expandRecurringEvents - monthly', () => {
it('expands monthly on the 6th', () => {
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSecureFetch } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
}));
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
}));
import {
normalizeServerUrl,
emailDomain,
isEmailAddress,
serverCandidates,
probeJmapServer,
discoverServerForEmail,
} from '../server-discovery';
const SESSION_DOC = { capabilities: {}, apiUrl: '/jmap/', primaryAccounts: {} };
/**
* Answers `status` for the listed base URLs and rejects everything else. A 200
* yields a JMAP session document unless a body is given explicitly.
*/
function respondFor(statuses: Record<string, number | { status: number; body: unknown }>) {
mockSecureFetch.mockImplementation(async (url: string) => {
const base = url.replace(/\/\.well-known\/jmap$/, '');
const entry = statuses[base];
if (entry === undefined) throw new TypeError('Network request failed');
const { status, body } =
typeof entry === 'number' ? { status: entry, body: SESSION_DOC } : entry;
return { status, json: async () => body } as Response;
});
}
describe('normalizeServerUrl', () => {
it('adds https:// to a bare host', () => {
expect(normalizeServerUrl('mail.example.com')).toBe('https://mail.example.com');
});
it('strips trailing slashes', () => {
expect(normalizeServerUrl('https://mail.example.com//')).toBe('https://mail.example.com');
});
it('strips a pasted JMAP session endpoint', () => {
expect(normalizeServerUrl('https://mail.example.com/.well-known/jmap')).toBe(
'https://mail.example.com',
);
expect(normalizeServerUrl('mail.example.com/jmap')).toBe('https://mail.example.com');
});
it('keeps an explicit http scheme and port', () => {
expect(normalizeServerUrl('http://localhost:8080')).toBe('http://localhost:8080');
});
it('rejects non-http schemes', () => {
expect(normalizeServerUrl('ftp://mail.example.com')).toBeNull();
expect(normalizeServerUrl('bulwarkmail://connect')).toBeNull();
});
it('rejects input that is not a host', () => {
expect(normalizeServerUrl('')).toBeNull();
expect(normalizeServerUrl(' ')).toBeNull();
expect(normalizeServerUrl('not a host')).toBeNull();
expect(normalizeServerUrl('ada@example.com')).toBeNull();
// A single label can't be a public host — except localhost.
expect(normalizeServerUrl('example')).toBeNull();
expect(normalizeServerUrl('localhost')).toBe('https://localhost');
});
});
describe('emailDomain', () => {
it('extracts and lowercases the domain', () => {
expect(emailDomain('Ada@Example.COM')).toBe('example.com');
});
it('rejects anything that is not an address', () => {
expect(emailDomain('ada')).toBeNull();
expect(emailDomain('ada@localhost')).toBeNull();
expect(emailDomain('a@b@example.com')).toBeNull();
expect(isEmailAddress('ada@example.com')).toBe(true);
expect(isEmailAddress('ada')).toBe(false);
});
});
describe('serverCandidates', () => {
it('orders the bare domain ahead of the conventional subdomains', () => {
expect(serverCandidates('example.com')).toEqual([
'https://example.com',
'https://mail.example.com',
'https://webmail.example.com',
]);
});
});
describe('probeJmapServer', () => {
beforeEach(() => vi.clearAllMocks());
it('treats 200 as a hit', async () => {
respondFor({ 'https://mail.example.com': 200 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
});
it('treats 401 as a hit — an unauthenticated probe is meant to be rejected', async () => {
respondFor({ 'https://mail.example.com': 401 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
});
it('treats 404 and transport failures as a miss', async () => {
respondFor({ 'https://mail.example.com': 404 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(false);
await expect(probeJmapServer('https://nothing.example.com')).resolves.toBe(false);
});
it('rejects a 200 that is not a session document', async () => {
// A catch-all web server or captive portal answering 200 for every path.
respondFor({ 'https://example.com': { status: 200, body: '<!doctype html>' } });
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
});
it('rejects a 200 whose body is not JSON at all', async () => {
mockSecureFetch.mockResolvedValue({
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token <');
},
} as unknown as Response);
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
});
it('gives up at the deadline instead of hanging', async () => {
mockSecureFetch.mockImplementation(
() => new Promise(() => {/* never settles */}),
);
await expect(probeJmapServer('https://slow.example.com', 20)).resolves.toBe(false);
});
});
describe('discoverServerForEmail', () => {
beforeEach(() => vi.clearAllMocks());
it('returns a known account server for the same domain without probing', async () => {
respondFor({});
const found = await discoverServerForEmail('ada@example.com', {
knownServerUrls: ['https://mail.example.com'],
});
expect(found).toBe('https://mail.example.com');
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('prefers the bare domain when several hosts answer', async () => {
respondFor({ 'https://example.com': 401, 'https://mail.example.com': 200 });
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe('https://example.com');
});
it('falls back to mail.<domain> when the bare domain is not a JMAP server', async () => {
respondFor({ 'https://example.com': 404, 'https://mail.example.com': 401 });
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
'https://mail.example.com',
);
});
it('skips a bare domain that only serves a marketing site', async () => {
respondFor({
'https://example.com': { status: 200, body: '<!doctype html>' },
'https://mail.example.com': 200,
});
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
'https://mail.example.com',
);
});
it('returns null when nothing answers, so the caller can ask', async () => {
respondFor({});
await expect(discoverServerForEmail('ada@example.org')).resolves.toBeNull();
});
it('returns null without touching the network for a non-address', async () => {
respondFor({});
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
expect(mockSecureFetch).not.toHaveBeenCalled();
});
});
+6
View File
@@ -263,6 +263,12 @@ export function buildEditorHtml(opts: {
reportChange();
reportHeight();
},
// Send-time readback (issue #9): RN asks for the live DOM content instead
// of trusting the last change message, which can lag typing or be lost
// entirely if the bridge breaks. The id lets RN match reply to request.
getHtml: function (id) {
post('htmlSnapshot', { id: id, html: editor.innerHTML });
},
focus: function () { editor.focus(); },
};
+41 -6
View File
@@ -1,4 +1,6 @@
import { Platform } from 'react-native';
import { Directory, File, Paths } from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
@@ -12,6 +14,7 @@ import {
import { getClientCertAlias, secureFetch } from './client-cert';
const RFC822 = 'message/rfc822';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
// Read the user's filename template + transform preferences for exports.
function emailFileOptions(): EmailFilenameOptions {
@@ -48,7 +51,32 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
// intent, so every `Sharing.shareAsync` below gets `downloaded.uri` and must
// NOT be pre-translated to `downloaded.contentUri`. `openWithViewer` is the
// one exception: it assembles the intent itself, so there it's the reverse —
// only a content URI is grantable to another app.
// Android-only: hand the file to whichever app owns the type (PDF viewer,
// gallery, video player, ...) rather than to the share sheet. Returns false
// when the handoff didn't happen so the caller can fall back to sharing —
// most often because no installed app handles the MIME type, in which case
// startActivityAsync rejects with ActivityNotFoundException.
async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
try {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
// Read-only grant, scoped to the receiving app for the life of the
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
// uses startActivityForResult, and a new task cancels that result.
data: file.contentUri,
type: mimeType,
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
return true;
} catch (e) {
console.warn('[attachments] no viewer for', mimeType, '- falling back to share:', e);
return false;
}
}
// Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader
@@ -99,6 +127,10 @@ export async function shareAttachment(
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
return;
}
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
@@ -111,8 +143,9 @@ export async function shareAttachment(
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
// so on both platforms we land the file in the document directory and hand it
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
// uses cache + share, which on most viewers opens directly without prompting.
// surfaces the system save dialog. Unlike the 'preview' counterpart
// (shareAttachment), this one keeps the share sheet on Android as well: "save
// a copy" is a share-sheet destination, not something a viewer app handles.
export async function downloadAttachment(
blobId: string,
name: string | undefined,
@@ -137,8 +170,8 @@ export async function downloadAttachment(
});
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
@@ -153,12 +186,14 @@ export async function shareEmailEml(
blobId: string,
email?: Email | null,
subjectFallback?: string,
// Owning account when the message lives in a shared/group mailbox.
accountId?: string,
): Promise<void> {
const filename = email
? emailExportFilename(email, emailFileOptions())
: safeFilename(subjectFallback);
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, dest.name, RFC822);
const url = getDownloadUrl(blobId, dest.name, RFC822, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
+102
View File
@@ -0,0 +1,102 @@
// Sign-in failures reach the UI as raw strings from the JMAP client — "Session
// discovery failed: 404 Not Found", "Network request failed". Those describe
// what the code was doing, not what the person should do next. This maps the
// ones we can recognise onto copy that names a likely cause and an action.
//
// Errors are matched by `name` and message text rather than `instanceof` so
// this module stays free of the api/ and expo dependency graph.
export interface LoginErrorCopy {
title: string;
detail?: string;
}
export interface LoginErrorContext {
/** Host shown in "can't reach X" copy. */
serverUrl?: string | null;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : typeof err === 'string' ? err : '';
}
function nameOf(err: unknown): string {
return err instanceof Error ? err.name : '';
}
function hostLabel(serverUrl: string | null | undefined): string {
if (!serverUrl) return 'the server';
const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
return host || 'the server';
}
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
const name = nameOf(err);
const message = messageOf(err);
const lower = message.toLowerCase();
const host = hostLabel(context.serverUrl);
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return {
title: "That didn't work",
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.',
};
}
if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
return {
title: `Couldn't verify ${host}`,
detail:
"The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
};
}
// The endpoint answered, but it isn't a JMAP server — almost always a
// mistyped host or a webmail that lives on a subpath.
if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) {
return {
title: `No mail server at ${host}`,
detail: 'Double-check the address, or scan a sign-in code from the webmail instead.',
};
}
if (
name === 'NetworkError' ||
name === 'TypeError' ||
name === 'AbortError' ||
lower.includes('network request failed') ||
lower.includes('failed to fetch') ||
lower.includes('timeout') ||
lower.includes('timed out')
) {
return {
title: `Can't reach ${host}`,
detail: 'Check your connection and the server address, then try again.',
};
}
if (lower.includes('pairing code')) {
return {
title: 'That code has expired',
detail: 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.',
};
}
if (lower.includes('state mismatch')) {
return {
title: 'Sign-in was interrupted',
detail: "The response didn't match the request we started. Try signing in again.",
};
}
if (lower.includes('maximum of') && lower.includes('accounts')) {
return { title: message };
}
// Unrecognised: show what we were told rather than inventing a cause.
return {
title: 'Sign-in failed',
detail: message || 'Something went wrong. Try again.',
};
}
+104 -8
View File
@@ -3,6 +3,33 @@ import type { Mailbox } from '../api/types';
export interface MailboxNode extends Mailbox {
children: MailboxNode[];
depth: number;
/**
* True for the virtual node that wraps a shared/group account's folders.
* It has no server-side mailbox behind it — it can be expanded but never
* selected or used as a move target.
*/
isAccountNode?: boolean;
}
// Id prefix for the virtual per-shared-account node. Matches the webmail's
// `shared-account-<accountId>` convention in [lib/utils.ts].
export const SHARED_ACCOUNT_NODE_PREFIX = 'shared-account-';
/** The user's own folders — everything not owned by a shared/group account. */
export function ownMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return mailboxes.filter((m) => !m.isShared);
}
/**
* Folders belonging to the same account as `mailboxId`. Role lookups (trash,
* archive, junk…) must run inside one account: moving a message out of a
* shared folder into the user's own Trash isn't a thing JMAP can express.
* Falls back to the user's own folders when the id isn't known.
*/
export function mailboxesForSiblingOf(mailboxes: Mailbox[], mailboxId: string | null): Mailbox[] {
const current = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined;
if (!current?.isShared) return ownMailboxes(mailboxes);
return mailboxes.filter((m) => m.isShared && m.accountId === current.accountId);
}
// Matches `ROLE_PRIORITY` from [lib/utils.ts] in the webmail.
@@ -53,7 +80,16 @@ function sortNodes(nodes: MailboxNode[]): void {
for (const node of nodes) sortNodes(node.children);
}
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
function recalcDepths(nodes: MailboxNode[], base: number): void {
for (const n of nodes) {
n.depth = base;
if (n.children.length > 0) recalcDepths(n.children, base + 1);
}
}
// Build the root nodes for one account's folders (parent links only resolve
// within an account — a shared folder can't nest under an own folder).
function buildRoots(mailboxes: Mailbox[]): MailboxNode[] {
const deduped = deduplicate(mailboxes);
const map = new Map<string, MailboxNode>();
const roots: MailboxNode[] = [];
@@ -69,16 +105,76 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
roots.push(node);
}
}
return roots;
}
const recalcDepths = (nodes: MailboxNode[], base: number) => {
for (const n of nodes) {
n.depth = base;
if (n.children.length > 0) recalcDepths(n.children, base + 1);
}
};
const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
/**
* Own folders first, then one collapsible node per shared/group account
* holding that account's folders — the same shape the webmail sidebar uses
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
* rather than being mixed into the user's folder list.
*/
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
const own = mailboxes.filter((m) => !m.isShared);
const shared = mailboxes.filter((m) => m.isShared);
const roots = buildRoots(own);
recalcDepths(roots, 0);
sortNodes(roots);
return roots;
if (shared.length === 0) return roots;
const byAccount = new Map<string, Mailbox[]>();
for (const m of shared) {
const accountId = m.accountId ?? 'unknown';
const list = byAccount.get(accountId);
if (list) list.push(m);
else byAccount.set(accountId, [m]);
}
const accountNodes: MailboxNode[] = [];
for (const [accountId, accountMailboxes] of byAccount) {
const accountRoots = buildRoots(accountMailboxes);
// Children sit at depth 1 so the account node reads as their header.
recalcDepths(accountRoots, 1);
sortNodes(accountRoots);
const accountName = accountMailboxes[0]?.accountName || accountId;
accountNodes.push({
id: `${SHARED_ACCOUNT_NODE_PREFIX}${accountId}`,
name: accountName,
sortOrder: 1000,
totalEmails: 0,
// Roll the account's unread up to the header so a collapsed section
// still shows there's something waiting.
unreadEmails: accountMailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0),
totalThreads: 0,
unreadThreads: 0,
myRights: ACCOUNT_NODE_RIGHTS,
isSubscribed: true,
accountId,
accountName,
isShared: true,
isAccountNode: true,
children: accountRoots,
depth: 0,
});
}
accountNodes.sort((a, b) => a.name.localeCompare(b.name));
return [...roots, ...accountNodes];
}
// Flatten the tree in traversal order, skipping children of collapsed nodes.
+16
View File
@@ -0,0 +1,16 @@
// Single source of truth for "this feature only exists on some platforms", so
// the UI, the stores and the native bridges all agree on what is reachable.
import { Platform } from 'react-native';
/**
* In-app sideload updates: download the release APK from GitHub and hand it to
* the system package installer (see `lib/install-update`).
*
* Android only. App Store Review guideline 2.5.2 forbids an iOS app from
* downloading and installing executable code, and there is no iOS equivalent of
* the installer intent anyway - iOS builds are updated through TestFlight or
* the App Store. The whole updater surface (banner, settings pane, launch-time
* check) is hidden when this is false.
*/
export const supportsSideloadUpdates = Platform.OS === 'android';
+170
View File
@@ -0,0 +1,170 @@
// Turn an email address into a JMAP server URL so the sign-in flow doesn't
// have to open with a "Server URL" field nobody knows the answer to.
//
// The probe is deliberately cheap and short-lived: a GET of
// `<base>/.well-known/jmap` against a handful of conventional hosts, all in
// parallel, with a hard deadline. Discovery is an optimisation — if it comes
// back empty the caller falls through to asking for the server by hand, so a
// slow or hostile network must never be worse than typing it in.
import { secureFetch } from './client-cert';
export const DISCOVERY_TIMEOUT_MS = 2500;
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/;
/**
* Accept what people actually type — `mail.example.com`, `example.com/`,
* `https://mail.example.com/.well-known/jmap` — and return the base URL the
* JMAP client expects, or null if it can't be one.
*/
export function normalizeServerUrl(input: string): string | null {
let value = input.trim();
if (!value) return null;
const scheme = SCHEME_RE.exec(value);
if (scheme) {
const protocol = scheme[1].toLowerCase();
if (protocol !== 'http' && protocol !== 'https') return null;
value = `${protocol}://${value.slice(scheme[0].length)}`;
} else {
value = `https://${value}`;
}
// Someone pasting the endpoint they found in the webmail's settings gets the
// same result as someone typing the address they use in the browser.
value = value
.replace(/\/+$/, '')
.replace(/\/\.well-known\/jmap$/i, '')
.replace(/\/jmap$/i, '')
.replace(/\/+$/, '');
const host = value.slice(value.indexOf('://') + 3).split('/')[0];
if (!host || /[\s@]/.test(host)) return null;
const hostname = host.split(':')[0].toLowerCase();
if (!hostname) return null;
if (!hostname.includes('.') && hostname !== 'localhost') return null;
return value;
}
export function emailDomain(email: string): string | null {
const match = EMAIL_RE.exec(email.trim());
return match ? match[1].toLowerCase() : null;
}
export function isEmailAddress(value: string): boolean {
return emailDomain(value) !== null;
}
function hostOf(url: string): string {
return url.slice(url.indexOf('://') + 3).split('/')[0].split(':')[0].toLowerCase();
}
/**
* Hosts worth probing for `domain`, most likely first. The order is also the
* tie-break order when several respond.
*/
export function serverCandidates(domain: string): string[] {
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
}
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise<T>((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch(() => {
clearTimeout(timer);
resolve(fallback);
});
});
}
function looksLikeSession(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
return 'capabilities' in body || 'apiUrl' in body || 'primaryAccounts' in body;
}
/**
* True when `baseUrl` looks like a JMAP server.
*
* A 401 counts as a hit — an unauthenticated probe of Stalwart's session
* endpoint is *supposed* to be rejected, and that rejection is proof the
* endpoint is there. A 200, on the other hand, has to actually contain a
* session document: a captive portal or a catch-all web server will happily
* answer 200 with HTML for any path we ask for.
*/
export async function probeJmapServer(
baseUrl: string,
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
): Promise<boolean> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
const request = (async () => {
try {
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Accept: 'application/json' },
signal: controller?.signal,
});
if (response.status === 401) return true;
if (response.status !== 200) return false;
return looksLikeSession(await response.json());
} catch {
return false;
}
})();
try {
// The abort signal doesn't reach the native client-certificate path, so
// race the whole thing rather than trusting the request to give up.
return await withTimeout(request, timeoutMs, false);
} finally {
clearTimeout(abortTimer);
}
}
export interface DiscoveryOptions {
/** Servers of already-registered accounts, trusted without a probe. */
knownServerUrls?: string[];
timeoutMs?: number;
}
/**
* Best guess at the mail server for `email`, or null if nothing answered in
* time. Callers treat null as "ask the user".
*/
export async function discoverServerForEmail(
email: string,
options: DiscoveryOptions = {},
): Promise<string | null> {
const domain = emailDomain(email);
if (!domain) return null;
// A server we're already signed in to for this domain needs no probe, and
// covers the common case of adding a second account on the same host.
const known = (options.knownServerUrls ?? [])
.map(normalizeServerUrl)
.filter((url): url is string => url !== null);
const alreadyKnown = known.find((url) => {
const host = hostOf(url);
return host === domain || host.endsWith(`.${domain}`);
});
if (alreadyKnown) return alreadyKnown;
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
const candidates = serverCandidates(domain);
const results = await Promise.all(
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)),
);
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
// both answer.
return results.find((url): url is string => url !== null) ?? null;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
Compose:
| {
mode?: 'reply' | 'replyAll' | 'forward';
+31 -5
View File
@@ -163,6 +163,7 @@ export default function CalendarScreen() {
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const storeCalendars = useCalendarStore((s) => s.calendars);
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
@@ -218,6 +219,28 @@ export default function CalendarScreen() {
[storeEvents, birthdayEvents],
);
// VTODO-only task lists (Todoist imports, per-project Thunderbird task
// lists) are sibling CalDAV collections that Calendar/get returns alongside
// event calendars. Their contents surface in the Tasks sheet, so keep them
// out of the calendar drawer and out of the event/import calendar pickers —
// listing them as calendars just produces duplicate/confusing entries. (#28)
const eventCalendars = React.useMemo(() => {
if (taskOnlyCalendarIds.length === 0) return allCalendars;
const taskOnly = new Set(taskOnlyCalendarIds);
return allCalendars.filter((cal) => !taskOnly.has(cal.id));
}, [allCalendars, taskOnlyCalendarIds]);
// The Tasks sheet keeps the full list but with task lists sorted first, so
// its create-picker (which defaults to the first writable calendar) targets
// a dedicated task list when the account has one.
const taskSheetCalendars = React.useMemo(() => {
if (taskOnlyCalendarIds.length === 0) return allCalendars;
const taskOnly = new Set(taskOnlyCalendarIds);
return [...allCalendars].sort(
(a, b) => Number(taskOnly.has(b.id)) - Number(taskOnly.has(a.id)),
);
}, [allCalendars, taskOnlyCalendarIds]);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
[allCalendars, hiddenCalendarIds],
@@ -521,7 +544,7 @@ export default function CalendarScreen() {
<EventModal
visible={modalVisible}
event={modalEvent}
calendars={allCalendars}
calendars={eventCalendars}
defaultDate={modalDate}
onSave={handleSave}
onDelete={handleDeleteFromModal}
@@ -537,7 +560,7 @@ export default function CalendarScreen() {
<CalendarSidebarDrawer
visible={sidebarVisible}
calendars={allCalendars}
calendars={eventCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
@@ -555,7 +578,7 @@ export default function CalendarScreen() {
<TasksSheet
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
calendars={taskSheetCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
@@ -565,8 +588,11 @@ export default function CalendarScreen() {
<ICalImportSheet
visible={importVisible}
// Import batch-creates against the primary account, so only offer the
// user's own calendars as targets.
calendars={displayCalendars.filter((cal) => !cal.isShared)}
// user's own event calendars (no shared calendars, no task lists) as
// targets.
calendars={eventCalendars.filter(
(cal) => !cal.isShared && cal.id !== BIRTHDAY_CALENDAR_ID,
)}
onClose={() => setImportVisible(false)}
onImport={importEvents}
/>
+29 -5
View File
@@ -23,6 +23,7 @@ import RichTextEditor, {
type RichTextSelectionState,
} from '../components/RichTextEditor';
import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
@@ -263,8 +264,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
const mode = route.params?.mode ?? 'compose';
const prefillTo = route.params?.prefillTo;
const mailboxes = useEmailStore((s) => s.mailboxes);
// Always the user's own Sent — composing on behalf of a shared account
// isn't supported, so a group account's Sent must never be picked up here.
const sentMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'sent'),
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
[mailboxes],
);
@@ -758,13 +761,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Catch the classic "I forgot the attachment" footgun. Only file attachments
// count - inline images don't satisfy the user's intent of attaching a file.
// Returns true if it's safe to proceed, false if the user cancelled.
const passesAttachmentReminder = async (): Promise<boolean> => {
const passesAttachmentReminder = async (html: string): Promise<boolean> => {
if (!attachmentReminderEnabled) return true;
const hasFileAttachment = attachments.some(
(a) => !a.inline && a.blobId && !a.error,
);
if (hasFileAttachment) return true;
const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase();
const haystack = `${subject}\n${htmlToPlainText(html)}`.toLowerCase();
const matchedKeyword = attachmentReminderKeywords.find((kw) => {
const k = kw.trim().toLowerCase();
if (!k) return false;
@@ -878,12 +881,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
if (!(await passesAttachmentReminder())) return;
// Read the body straight from the editor DOM at send time. The `change`
// messages that feed `bodyHtml` are async and best-effort — trusting them
// here once shipped replies with the typed text silently missing when the
// page script had died (issue #9). If the editor doesn't answer we abort
// loudly rather than send possibly-stale content.
let liveBodyHtml: string;
try {
liveBodyHtml = (await editorRef.current?.getHtml()) ?? bodyHtml;
} catch {
Alert.alert(
t('email_composer.send_failed', 'Send failed'),
t(
'email_composer.editor_unavailable',
'Could not read the message content. Copy your text, then close and reopen the composer.',
),
);
return;
}
if (liveBodyHtml !== bodyHtml) setBodyHtml(liveBodyHtml);
if (!(await passesAttachmentReminder(liveBodyHtml))) return;
setSending(true);
try {
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml);
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(liveBodyHtml);
// Belt-and-suspenders sanitization: the editor uses execCommand which
// can preserve pasted <script>/<style>/etc. Strip them before sending.
const safeHtml = stripDangerousTags(rewrittenHtml);
+55 -13
View File
@@ -24,7 +24,9 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree';
import {
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
} from '../lib/mailbox-tree';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
@@ -46,6 +48,9 @@ function isPinned(email: Email): boolean {
return !!email.keywords?.$important;
}
// Height of the sender line when the avatar is hidden (extra-compact density),
// used to anchor the unread dot on that first line.
const UNREAD_DOT_TEXT_LINE = 20;
const EmailRow = React.memo(function EmailRow({
item,
@@ -92,6 +97,14 @@ const EmailRow = React.memo(function EmailRow({
onLongPress={handleLongPress}
delayLongPress={300}
>
{unread && (
<View
style={[
styles.unreadDot,
{ top: density.rowPaddingVertical + (density.showAvatar ? componentSizes.avatarMd : UNREAD_DOT_TEXT_LINE) / 2 - 4 },
]}
/>
)}
{selectionMode && (
<View style={styles.rowCheckboxWrap}>
{selected ? (
@@ -232,24 +245,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
return counts;
}, [emails, disableThreading]);
// Role folders must come from the account the open folder belongs to: a
// shared (group account) message can only be filed into that account's
// Archive/Trash/Junk, never the user's own.
const scopedMailboxes = React.useMemo(
() => mailboxesForSiblingOf(mailboxes, currentMailboxId),
[mailboxes, currentMailboxId],
);
const archiveMailboxId = React.useMemo(
() => findArchiveMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findArchiveMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
const trashMailboxId = React.useMemo(
() => findTrashMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findTrashMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
const junkMailboxId = React.useMemo(
() => findJunkMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findJunkMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
const [importing, setImporting] = React.useState(false);
const handleImport = React.useCallback(async () => {
const targetMailboxId =
currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null;
currentMailboxId ?? ownMailboxes(mailboxes).find((m) => m.role === 'inbox')?.id ?? null;
if (!targetMailboxId || importing) return;
let result: DocumentPicker.DocumentPickerResult;
try {
@@ -449,7 +469,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
};
const handleBulkDelete = async () => {
const trash = findTrashMailbox(mailboxes);
const trash = findTrashMailbox(scopedMailboxes);
if (!trash || !currentMailboxId) {
clearSelection();
return;
@@ -537,7 +557,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
React.useEffect(() => {
if (mailboxes.length > 0 && !currentMailboxId) {
const inbox = mailboxes.find((m) => m.role === 'inbox') || mailboxes[0];
const own = ownMailboxes(mailboxes);
const inbox = own.find((m) => m.role === 'inbox') || own[0];
if (!inbox) return;
void selectMailbox(inbox.id);
}
}, [mailboxes, currentMailboxId, selectMailbox]);
@@ -609,7 +631,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>
{currentMailbox?.isShared && currentMailbox.accountName
// Name the owning group account — a bare "Inbox" would look like
// the user's own.
? `${currentMailbox.accountName} · ${currentMailbox.name}`
: currentMailbox?.name ?? 'Inbox'}
</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
@@ -1002,7 +1030,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MoveSheet
visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={(toId) => {
const id = pendingMoveId;
@@ -1016,7 +1044,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={handleBatchMovePick}
/>
@@ -1365,6 +1393,20 @@ function makeStyles(c: ThemePalette) {
},
emailRowPressed: { backgroundColor: c.surface },
emailRowSelected: { backgroundColor: c.selection },
// Mirrors the webmail's unread indicator: an 8px filled circle at the row's
// start edge, vertically centered (email-list-item.tsx, fill-unread). The
// weight/color change alone is too subtle on some device fonts (#27).
// `top` is set per row: the dot is anchored on the row's *first* line (top
// padding + half an avatar), not on the row's midpoint, which would drop it
// a full line below the checkbox and avatar it reads as a column with.
unreadDot: {
position: 'absolute',
left: 4,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: c.unread,
},
rowCheckboxWrap: {
width: 16,
height: componentSizes.avatarMd,
+6 -4
View File
@@ -15,7 +15,9 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { blobId, subject } = route.params;
// Blobs are account-scoped, so a message from a shared/group mailbox has
// to be fetched against its owning account.
const { blobId, subject, jmapAccountId } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -23,18 +25,18 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
let cancelled = false;
void (async () => {
try {
const text = await fetchRawEmail(blobId);
const text = await fetchRawEmail(blobId, jmapAccountId);
if (!cancelled) setRaw(text);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
}
})();
return () => { cancelled = true; };
}, [blobId]);
}, [blobId, jmapAccountId]);
const onShare = async () => {
try {
await shareEmailEml(blobId, undefined, subject);
await shareEmailEml(blobId, undefined, subject, jmapAccountId);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
+51 -26
View File
@@ -30,7 +30,7 @@ import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { findTrashMailbox } from '../lib/mailbox-tree';
import { findTrashMailbox, mailboxesForSiblingOf } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -84,6 +84,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
// The JMAP account the open message belongs to: the route param when the
// unified inbox opened a group message, otherwise the account behind the
// folder it was opened from. Undefined means the user's own mail.
const ownerAccountId = React.useMemo(() => {
if (jmapAccountId) return jmapAccountId;
const current = currentMailboxId
? mailboxes.find((m) => m.id === currentMailboxId)
: undefined;
return current?.isShared ? current.accountId : undefined;
}, [jmapAccountId, mailboxes, currentMailboxId]);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
@@ -128,13 +139,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
const pending = inFlight.get(id);
if (pending) return pending;
const p = getEmailDetail(id, jmapAccountId)
const p = getEmailDetail(id, ownerAccountId)
.then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; })
.catch(() => detailCache.get(id) ?? null)
.finally(() => { inFlight.delete(id); });
inFlight.set(id, p);
return p;
}, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]);
}, [getEmailDetail, ownerAccountId, detailCache, bumpCache, inFlight]);
// Slide to a page by index and reflect it in the toolbar immediately; a swipe
// that settles on the same page confirms the same id via onMomentumEnd.
@@ -190,9 +201,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setDownloadingBlobId(blobId);
try {
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type, target);
await downloadAttachment(blobId, name, type, target, ownerAccountId);
} else {
await shareAttachment(blobId, name, type, target);
await shareAttachment(blobId, name, type, target, ownerAccountId);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
@@ -200,7 +211,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setDownloadingBlobId(null);
}
},
[downloadingBlobId, mailAttachmentAction],
[downloadingBlobId, mailAttachmentAction, ownerAccountId],
);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -226,10 +237,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
if (!cancelled) void markRead(activeEmailId, ownerAccountId);
}, markAsReadDelay);
} else {
void markRead(activeEmailId, jmapAccountId);
void markRead(activeEmailId, ownerAccountId);
}
}
})();
@@ -237,24 +248,33 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, ownerAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
// Move/archive/spam targets have to live in the same account as the folder
// the message was opened from — a shared (group account) message can't be
// filed into the user's own folders.
const scopedMailboxes = React.useMemo(
() => mailboxesForSiblingOf(mailboxes, currentMailboxId),
[mailboxes, currentMailboxId],
);
const archiveMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'archive'),
[scopedMailboxes],
);
const junkMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[scopedMailboxes],
);
const inboxMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'inbox'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'inbox'),
[scopedMailboxes],
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
// `mailboxIds` comes back from the server unprefixed, so compare on the
// folder's raw id rather than the sidebar key.
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.originalId ?? junkMailbox.id]);
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
@@ -271,7 +291,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId);
};
// Toggle the star on a specific message — used both by the toolbar (current
@@ -282,27 +302,27 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
else next.$flagged = true;
const prev = detailCache.get(target.id);
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
void setEmailKeywords(target.id, next);
}, [detailCache, bumpCache]);
void setEmailKeywords(target.id, next, ownerAccountId);
}, [detailCache, bumpCache, ownerAccountId]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
void markRead(email.id, ownerAccountId);
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId);
}
};
const onDelete = () => {
if (!email || !currentMailboxId) return;
const trash = findTrashMailbox(mailboxes);
const trash = findTrashMailbox(scopedMailboxes);
if (!trash) {
Alert.alert(
t('email_list.error', 'Error'),
@@ -325,7 +345,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!email || !currentMailboxId) return;
setMoreMenuOpen(false);
if (isInJunk) {
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
const target = inboxMailbox ?? scopedMailboxes.find((m) => m.id !== currentMailboxId);
if (!target) return;
void moveToMailbox(email.id, currentMailboxId, target.id);
} else {
@@ -534,6 +554,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<EmailPane
id={item.id}
email={detailCache.get(item.id) ?? null}
jmapAccountId={ownerAccountId}
ensureDetail={ensureDetail}
c={c}
styles={styles}
@@ -604,6 +625,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
emailId: email.id,
blobId: email.blobId,
subject: email.subject,
jmapAccountId: ownerAccountId,
});
}
}}
@@ -611,7 +633,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email);
await shareEmailEml(email.blobId, email, undefined, ownerAccountId);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
@@ -621,7 +643,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<MoveSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={onMoveToMailbox}
/>
@@ -640,6 +662,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
interface EmailPaneProps {
id: string;
email: Email | null;
/** Owning account when the message lives in a shared/group mailbox. */
jmapAccountId?: string;
ensureDetail: (id: string) => Promise<Email | null>;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
@@ -659,7 +683,7 @@ interface EmailPaneProps {
// scroll position and "show all attachments" toggle, and renders directly from
// the email passed to it — neighbours show real content, not a placeholder.
function EmailPane({
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
id, email, jmapAccountId, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
onZoomChange,
}: EmailPaneProps) {
@@ -806,6 +830,7 @@ function EmailPane({
<EmailBodyView
email={email}
senderEmail={from?.email}
jmapAccountId={jmapAccountId}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
/>
+345 -270
View File
@@ -1,16 +1,23 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
import {
discoverServerForEmail,
emailDomain,
isEmailAddress,
normalizeServerUrl,
} from '../lib/server-discovery';
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
import LoginShell from './login/LoginShell';
import ChooseStep from './login/ChooseStep';
import EmailStep from './login/EmailStep';
import ServerStep from './login/ServerStep';
import ConfirmStep from './login/ConfirmStep';
import PasswordStep from './login/PasswordStep';
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
interface LoginScreenProps {
onLogin?: () => void;
@@ -18,288 +25,356 @@ interface LoginScreenProps {
onCancel?: () => void;
}
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
/**
* Sign-in, one question per screen, cheapest question first.
*
* The routes that need no server address — a paired QR code, or an email we
* can discover a server from — come first; the manual server + password form
* is the fallback rather than the greeting. Every step drives one of the
* existing auth-store actions, so the credential handling below is unchanged
* from the single-form version this replaced.
*/
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
const storeError = useAuthStore((state) => state.error);
const [serverUrl, setServerUrl] = React.useState('');
const accounts = useAccountStore((state) => state.accounts);
const activeAccountId = useAccountStore((state) => state.activeAccountId);
const [step, setStep] = React.useState<StepName>('choose');
const [history, setHistory] = React.useState<StepName[]>([]);
const [email, setEmail] = React.useState('');
const [serverInput, setServerInput] = React.useState('');
const [serverUrl, setServerUrl] = React.useState('');
// Drives the confirm step's heading: we only claim to have "found" a server
// when discovery actually found one.
const [serverDiscovered, setServerDiscovered] = React.useState(false);
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
const [searching, setSearching] = React.useState(false);
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
const [scannerVisible, setScannerVisible] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
// Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
const knownServerUrl = React.useMemo(() => {
const active = accounts.find((a) => a.id === activeAccountId);
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
}, [accounts, activeAccountId]);
const handleLogin = async () => {
if (!canSubmit) {
return;
}
try {
await login(serverUrl.trim(), email.trim(), password, { addAccount: isAddMode });
onLogin?.();
} catch {
// Store state already contains the user-facing error.
}
};
const handleWebmailHandoff = async () => {
if (!canHandoff) return;
try {
const before = useAuthStore.getState().isAuthenticated;
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
// loginViaWebmail swallows the "user cancelled the browser" case
// silently — only fire the navigation hook when authentication
// actually completed.
const after = useAuthStore.getState().isAuthenticated;
if (after && !before) {
onLogin?.();
} else if (isAddMode && after) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const handleScanned = async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
return;
}
try {
const before = useAuthStore.getState().isAuthenticated;
if (payload.kind === 'connect') {
// Server-bootstrap QR: fill the field and run the normal browser
// handoff so the user still authenticates in the webmail.
setServerUrl(payload.webmailUrl);
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
} else {
// Cross-device pairing QR: redeem the one-time code for tokens, no
// browser round-trip needed.
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
}
const after = useAuthStore.getState().isAuthenticated;
if ((after && !before) || (isAddMode && after)) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
clearError();
}
setter(value);
const goTo = React.useCallback(
(next: StepName) => {
clearError();
setNotice(null);
setHistory((prev) => [...prev, step]);
setStep(next);
},
[clearError],
[clearError, step],
);
const onChangeServerUrl = React.useMemo(() => updateField(setServerUrl), [updateField]);
const onChangeEmail = React.useMemo(() => updateField(setEmail), [updateField]);
const onChangePassword = React.useMemo(() => updateField(setPassword), [updateField]);
const goBack = React.useCallback(() => {
if (history.length === 0) return;
clearError();
setNotice(null);
setStep(history[history.length - 1]);
setHistory((prev) => prev.slice(0, -1));
}, [clearError, history]);
// Android hardware back mirrors the on-screen back: one step at a time, and
// out of the flow entirely from the first step.
React.useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (busy || searching) return true;
if (scannerVisible) {
setScannerVisible(false);
return true;
}
if (history.length > 0) {
goBack();
return true;
}
if (isAddMode && onCancel) {
onCancel();
return true;
}
return false;
});
return () => subscription.remove();
}, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]);
// ── flows ──────────────────────────────────────────────
const finishIfSignedIn = React.useCallback(
(wasAuthenticated: boolean) => {
const isAuthenticated = useAuthStore.getState().isAuthenticated;
// In add mode the store was already authenticated before we started, so
// the before/after comparison can't be the only signal.
if ((isAuthenticated && !wasAuthenticated) || (isAddMode && isAuthenticated)) {
onLogin?.();
return true;
}
return false;
},
[isAddMode, onLogin],
);
const runHandoff = React.useCallback(
async (target: string) => {
setNotice(null);
setBusy('browser');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaWebmail(target, { addAccount: isAddMode });
// loginViaWebmail resolves quietly when the user closes the browser —
// that's a cancellation, not a failure, so leave them where they were.
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: target }));
} finally {
setBusy(null);
}
},
[finishIfSignedIn, isAddMode, loginViaWebmail],
);
const handleEmailContinue = React.useCallback(async () => {
const value = email.trim();
if (!isEmailAddress(value)) {
setNotice({
title: 'Enter a full email address',
detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.',
});
return;
}
setNotice(null);
setSearching(true);
try {
const found = await discoverServerForEmail(value, { knownServerUrls });
if (found) {
setServerUrl(found);
setServerInput(found);
setServerDiscovered(true);
goTo('confirm');
} else {
setFailedDomain(emailDomain(value));
setServerInput('');
goTo('server');
}
} finally {
setSearching(false);
}
}, [email, goTo, knownServerUrls]);
const handleServerContinue = React.useCallback(() => {
const normalized = normalizeServerUrl(serverInput);
if (!normalized) {
setNotice({
title: "That doesn't look like a server address",
detail: 'Try the address you use for webmail, like mail.example.com.',
});
return;
}
setServerUrl(normalized);
setServerDiscovered(false);
goTo('confirm');
}, [goTo, serverInput]);
const handlePasswordSubmit = React.useCallback(async () => {
const target = serverUrl || normalizeServerUrl(serverInput);
if (!target) {
setNotice({ title: 'We need a server address first' });
return;
}
if (!email.trim() || !password) {
setNotice({ title: 'Enter your email and password' });
return;
}
setNotice(null);
setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await login(target, email.trim(), password, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: target }));
} finally {
setBusy(null);
}
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]);
const handleScanned = React.useCallback(
async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
setNotice({
title: "That code isn't a Bulwark sign-in code",
detail: 'Open Bulwark on the web, then Settings → Devices to show one.',
});
return;
}
if (payload.kind === 'connect') {
// Server-bootstrap code: it carries the address, the user still
// authenticates in the webmail.
setServerUrl(payload.webmailUrl);
setServerInput(payload.webmailUrl);
await runHandoff(payload.webmailUrl);
return;
}
// Cross-device pairing code: redeem it for tokens, no browser needed.
setNotice(null);
setBusy('pairing');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl }));
} finally {
setBusy(null);
}
},
[finishIfSignedIn, isAddMode, loginViaPairing, runHandoff],
);
// ── render ─────────────────────────────────────────────
if (busy) {
return (
<SigningInStep
phase={busy}
serverUrl={serverUrl || knownServerUrl}
email={email.trim() || null}
/>
);
}
const canGoBack = history.length > 0;
const shellProps = {
onBack: canGoBack ? goBack : undefined,
onClose: isAddMode && onCancel ? onCancel : undefined,
};
if (step === 'choose') {
// A leftover store error ("Session expired") is the reason some people
// land back here, so it belongs on this screen.
const chooseNotice = notice ?? (storeError ? { title: storeError } : null);
return (
<>
<LoginShell {...shellProps} centered showFooter>
<ChooseStep
isAddMode={isAddMode}
accounts={accounts}
knownServerUrl={isAddMode ? knownServerUrl : null}
notice={chooseNotice}
onScan={() => setScannerVisible(true)}
onUseEmail={() => goTo('email')}
onUseKnownServer={() => {
if (!knownServerUrl) return;
setServerUrl(knownServerUrl);
setServerInput(knownServerUrl);
setServerDiscovered(false);
goTo('confirm');
}}
onManualSetup={() => {
setFailedDomain(null);
goTo('server');
}}
/>
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => void handleScanned(data)}
/>
</>
);
}
return (
<SafeAreaView style={styles.container}>
{isAddMode && onCancel ? (
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
<X size={24} color={c.text} />
</Pressable>
) : null}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardView}
>
<View style={styles.content}>
{/* Logo & Branding */}
<View style={styles.branding}>
<View style={styles.logoContainer}>
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.logoImage}
resizeMode="contain"
/>
</View>
<Text style={styles.appName}>{isAddMode ? 'Add account' : 'Bulwark Mail'}</Text>
<Text style={styles.tagline}>
{isAddMode ? 'Sign in to a second account' : 'Secure. Private. Yours.'}
</Text>
</View>
<>
<LoginShell {...shellProps}>
{step === 'email' ? (
<EmailStep
isAddMode={isAddMode}
value={email}
onChange={(value) => {
setNotice(null);
setEmail(value);
}}
onSubmit={() => void handleEmailContinue()}
onKnowServer={() => {
setFailedDomain(null);
goTo('server');
}}
isSearching={searching}
notice={notice}
/>
) : null}
{/* Login Form */}
<View style={styles.form}>
<Input
label="Server URL"
placeholder="https://mail.example.com"
value={serverUrl}
onChangeText={onChangeServerUrl}
autoCapitalize="none"
keyboardType="url"
autoCorrect={false}
/>
{step === 'server' ? (
<ServerStep
value={serverInput}
onChange={(value) => {
setNotice(null);
setServerInput(value);
}}
onSubmit={handleServerContinue}
onScan={() => setScannerVisible(true)}
failedDomain={failedDomain}
notice={notice}
/>
) : null}
<Input
label="Email or Username"
placeholder="you@example.com"
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"
keyboardType="email-address"
autoCorrect={false}
/>
{step === 'confirm' ? (
<ConfirmStep
serverUrl={serverUrl}
discovered={serverDiscovered}
onContinue={() => void runHandoff(serverUrl)}
onChangeServer={() => {
setFailedDomain(null);
goTo('server');
}}
onUsePassword={() => goTo('password')}
notice={notice}
/>
) : null}
<Input
label="Password"
placeholder="Enter your password"
value={password}
onChangeText={onChangePassword}
secureTextEntry={!showPassword}
onSubmitEditing={() => {
void handleLogin();
}}
rightIcon={
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={20} color={c.textMuted} />
) : (
<Eye size={20} color={c.textMuted} />
)}
</Pressable>
}
/>
<Button
variant="default"
size="lg"
onPress={() => {
void handleLogin();
}}
disabled={!canSubmit}
loading={isLoading}
style={styles.loginButton}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{/* OAuth divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button
variant="outline"
size="md"
onPress={() => {
void handleWebmailHandoff();
}}
disabled={!canHandoff || isLoading}
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
<Text style={styles.footerLink}>Privacy Policy</Text>
</View>
</View>
</KeyboardAvoidingView>
{step === 'password' ? (
<PasswordStep
serverUrl={serverUrl}
email={email}
password={password}
onChangeEmail={(value) => {
setNotice(null);
setEmail(value);
}}
onChangePassword={(value) => {
setNotice(null);
setPassword(value);
}}
onSubmit={() => void handlePasswordSubmit()}
notice={notice}
/>
) : null}
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
onScanned={(data) => void handleScanned(data)}
/>
</SafeAreaView>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
cancelButton: {
position: 'absolute',
top: 50,
left: 16,
zIndex: 10,
padding: 8,
},
keyboardView: { flex: 1 },
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xxl,
},
branding: { alignItems: 'center', marginBottom: 40 },
logoContainer: {
width: 80,
height: 80,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
logoImage: {
width: 72,
height: 72,
},
appName: { ...typography.h1, color: c.text },
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
form: { gap: spacing.lg },
loginButton: {
marginTop: spacing.sm,
},
errorText: {
...typography.caption,
color: c.error,
textAlign: 'center',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.md,
gap: spacing.md,
},
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
dividerText: { ...typography.caption, color: c.textMuted },
oauthRow: { gap: spacing.md },
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
footerText: { ...typography.caption, color: c.textMuted },
footerLink: { ...typography.caption, color: c.textLink },
});
}
+9 -1
View File
@@ -38,6 +38,7 @@ import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -138,11 +139,18 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
updates: UpdatesSettings,
};
// Tabs whose feature does not exist on this platform never appear in the list.
// The "Updates" pane drives the sideload installer, which is Android-only - the
// current version and build are still shown under "About & Data".
const AVAILABLE_TABS: TabDef[] = TABS.filter(
(t) => t.id !== 'updates' || supportsSideloadUpdates,
);
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
label: GROUP_LABELS[group],
items: TABS.filter(t => t.group === group),
items: AVAILABLE_TABS.filter(t => t.group === group),
})).filter(g => g.items.length > 0);
}
+14
View File
@@ -96,6 +96,7 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
onPress={() => onOpen(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
{unread && <View style={styles.unreadDot} />}
<View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item)}
@@ -219,6 +220,19 @@ function makeStyles(c: ThemePalette) {
paddingVertical: spacing.md,
},
rowPressed: { backgroundColor: c.surfaceHover },
// Same unread indicator as the mailbox list (and the webmail): an 8px
// filled circle in the start gutter (#27). Anchored on the row's *first*
// line — top padding + half an avatar — because centring it on the row
// drops it a full line below the avatar it reads as a column with.
unreadDot: {
position: 'absolute',
left: 4,
top: spacing.md + componentSizes.avatarMd / 2 - 4,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: c.unread,
},
avatarWrap: { position: 'relative' },
accountDot: {
position: 'absolute',
+182
View File
@@ -0,0 +1,182 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
import { QrCode, Mail, Plus, Server } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice';
// The white mark disappears on the light palette, so pick per theme. `require`
// can't take an expression, hence the pair.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
interface ChooseStepProps {
isAddMode: boolean;
accounts: AccountEntry[];
/** Host of the signed-in account, offered as a shortcut in add mode. */
knownServerUrl: string | null;
onScan: () => void;
onUseEmail: () => void;
onUseKnownServer: () => void;
onManualSetup: () => void;
notice?: { title: string; detail?: string } | null;
disabled?: boolean;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
function initialsOf(account: AccountEntry): string {
const source = account.displayName || account.email || account.username;
const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || source[0] || '?').toUpperCase();
}
/**
* The entry point. Leads with the two routes that don't require knowing a
* server address; manual setup stays one tap away for people who do.
*/
export default function ChooseStep({
isAddMode,
accounts,
knownServerUrl,
onScan,
onUseEmail,
onUseKnownServer,
onManualSetup,
notice,
disabled = false,
}: ChooseStepProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
{isAddMode ? (
<View style={styles.addHeading}>
<Text style={styles.title}>Add another account</Text>
{accounts.length > 0 ? (
<Text style={styles.subtitle}>You&apos;re signed in to these already.</Text>
) : null}
</View>
) : (
<View style={styles.branding}>
<Image
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.title}>Bulwark Mail</Text>
<Text style={styles.subtitle}>Choose how you&apos;d like to sign in.</Text>
</View>
)}
{isAddMode && accounts.length > 0 ? (
<View style={styles.accountList}>
{accounts.map((account) => (
<View key={account.id} style={styles.accountRow}>
<View style={[styles.avatar, { backgroundColor: account.avatarColor }]}>
<Text style={styles.avatarText}>{initialsOf(account)}</Text>
</View>
<View style={styles.accountText}>
<Text style={styles.accountName} numberOfLines={1}>
{account.displayName || account.username}
</Text>
<Text style={styles.accountEmail} numberOfLines={1}>
{account.email || account.username}
</Text>
</View>
</View>
))}
</View>
) : null}
{notice ? (
<View style={styles.notice}>
<LoginNotice title={notice.title} detail={notice.detail} />
</View>
) : null}
<View style={styles.options}>
{isAddMode && knownServerUrl ? (
<OptionTile
emphasis="primary"
title={`Another account on ${hostOf(knownServerUrl)}`}
description="We already know this server, so there's no setup"
renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={onUseKnownServer}
disabled={disabled}
/>
) : (
<OptionTile
emphasis="primary"
title="Scan a sign-in code"
description="The fastest way in if you're signed in on the web"
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
)}
<OptionTile
title={isAddMode ? 'An account somewhere else' : 'Use my email address'}
description="We'll find your mail server"
renderIcon={(color, size) => <Mail size={size} color={color} />}
onPress={onUseEmail}
disabled={disabled}
/>
{isAddMode && knownServerUrl ? (
<OptionTile
title="Scan a sign-in code"
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
) : null}
</View>
<Pressable onPress={onManualSetup} disabled={disabled} hitSlop={8} style={styles.manual}>
<Server size={14} color={c.textMuted} />
<Text style={styles.manualText}>Enter server details manually</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.xl },
branding: { gap: spacing.xs, marginBottom: spacing.md },
logo: { width: 40, height: 40, marginBottom: spacing.sm },
addHeading: { gap: spacing.xs },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
accountList: { gap: spacing.md },
accountRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
avatar: { width: 36, height: 36, borderRadius: radius.full, alignItems: 'center', justifyContent: 'center' },
avatarText: { ...typography.bodySemibold, color: c.primaryForeground },
accountText: { flex: 1 },
accountName: { ...typography.bodyMedium, color: c.text },
accountEmail: { ...typography.caption, color: c.textMuted },
notice: { marginTop: -spacing.xs },
options: { gap: spacing.md },
manual: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
manualText: { ...typography.body, color: c.textMuted },
});
}
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Globe } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '../../components';
import LoginNotice from './LoginNotice';
interface ConfirmStepProps {
serverUrl: string;
/** False when the user supplied the address — we didn't find anything. */
discovered: boolean;
onContinue: () => void;
onChangeServer: () => void;
onUsePassword: () => void;
notice?: { title: string; detail?: string } | null;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
/**
* Names the server before the browser opens. A sign-in that jumps to a browser
* with no warning reads as a bug; saying which server, and why the password
* isn't asked for here, turns the detour into the point.
*/
export default function ConfirmStep({
serverUrl,
discovered,
onContinue,
onChangeServer,
onUsePassword,
notice,
}: ConfirmStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>
{discovered ? 'Found your mail server' : 'Check this looks right'}
</Text>
<Text style={styles.subtitle}>Next you&apos;ll sign in on your provider&apos;s own page.</Text>
</View>
<View style={styles.card}>
<View style={styles.cardIcon}>
<Globe size={20} color={c.primary} />
</View>
<View style={styles.cardText}>
<Text style={styles.cardTitle} numberOfLines={1}>
{hostOf(serverUrl)}
</Text>
{!serverUrl.startsWith('https://') ? (
<View style={styles.cardMetaRow}>
<View style={styles.dot} />
<Text style={styles.cardMeta}>Unencrypted connection</Text>
</View>
) : null}
</View>
</View>
<LoginNotice
tone="info"
title="Your password stays with your provider"
detail="You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time."
/>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onContinue}>
Continue
</Button>
<View style={styles.links}>
<Pressable onPress={onUsePassword} hitSlop={8}>
<Text style={styles.link}>Sign in with a password instead</Text>
</Pressable>
<Pressable onPress={onChangeServer} hitSlop={8}>
<Text style={styles.linkMuted}>Use a different server</Text>
</Pressable>
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
borderRadius: radius.lg,
padding: spacing.md,
},
cardIcon: { width: 24, alignItems: 'center' },
cardText: { flex: 1, gap: 2 },
cardTitle: { ...typography.bodyMedium, color: c.text },
cardMetaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.warning },
cardMeta: { ...typography.caption, color: c.textSecondary },
links: { alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs },
link: { ...typography.body, color: c.textLink },
linkMuted: { ...typography.body, color: c.textMuted },
});
}
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Mail } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface EmailStepProps {
isAddMode: boolean;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onKnowServer: () => void;
isSearching: boolean;
notice?: { title: string; detail?: string } | null;
}
/**
* One field, keyboard already up. The button stays enabled and validates on
* press, so nobody is left guessing which field disabled it.
*/
export default function EmailStep({
isAddMode,
value,
onChange,
onSubmit,
onKnowServer,
isSearching,
notice,
}: EmailStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>
{isAddMode ? "What's the other address?" : "What's your email address?"}
</Text>
<Text style={styles.subtitle}>
That&apos;s usually all we need to find your mail server.
</Text>
</View>
<Input
placeholder="you@example.com"
value={value}
onChangeText={onChange}
autoFocus
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
returnKeyType="go"
editable={!isSearching}
onSubmitEditing={onSubmit}
leftIcon={<Mail size={17} color={c.textMuted} />}
/>
<Text style={styles.hint}>Works with Stalwart and any other JMAP mail server.</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit} loading={isSearching}>
{isSearching ? 'Looking up your server…' : 'Continue'}
</Button>
<Pressable onPress={onKnowServer} disabled={isSearching} hitSlop={8} style={styles.link}>
<Text style={styles.linkText}>I know my server address</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
link: { alignItems: 'center', paddingVertical: spacing.sm },
linkText: { ...typography.body, color: c.textMuted },
});
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { AlertTriangle, Lock } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface LoginNoticeProps {
title: string;
detail?: string;
tone?: 'error' | 'info';
}
/** Inline banner for a sign-in failure, or a reassurance about where a
* password ends up. Errors lead with what went wrong, then what to do. */
export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isError = tone === 'error';
const Icon = isError ? AlertTriangle : Lock;
return (
<View
style={[styles.banner, isError ? styles.bannerError : styles.bannerInfo]}
accessibilityLiveRegion={isError ? 'polite' : 'none'}
>
<Icon size={16} color={isError ? c.error : c.textMuted} style={styles.icon} />
<View style={styles.text}>
<Text style={[styles.title, isError ? styles.titleError : styles.titleInfo]}>{title}</Text>
{detail ? <Text style={styles.detail}>{detail}</Text> : null}
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
gap: spacing.sm,
borderRadius: radius.md,
padding: spacing.md,
},
bannerError: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.errorBorder },
bannerInfo: { backgroundColor: 'transparent', paddingHorizontal: 0 },
icon: { marginTop: 1 },
text: { flex: 1, gap: 2 },
title: { ...typography.captionMedium },
titleError: { color: c.error },
titleInfo: { color: c.textSecondary },
detail: { ...typography.caption, color: c.textSecondary, lineHeight: 17 },
});
}
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import {
View,
Text,
Pressable,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { ArrowLeft, X } from 'lucide-react-native';
import { spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
interface LoginShellProps {
children: React.ReactNode;
/** Renders a back chevron. Omit on the first step of the flow. */
onBack?: () => void;
/** Renders a close X on the right — add-account mode only. */
onClose?: () => void;
/** Vertically centres the content instead of stacking from the top. */
centered?: boolean;
showFooter?: boolean;
}
/**
* Shared chrome for every sign-in step: safe area, keyboard avoidance, the
* back/close affordances, and the version footer. Steps supply only their own
* content so they all sit on the same grid.
*/
export default function LoginShell({
children,
onBack,
onClose,
centered = false,
showFooter = false,
}: LoginShellProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.flex}
>
<View style={styles.header}>
{onBack ? (
<Pressable onPress={onBack} hitSlop={12} style={styles.headerButton}>
<ArrowLeft size={22} color={c.textSecondary} />
</Pressable>
) : (
<View style={styles.headerButton} />
)}
{onClose ? (
<Pressable onPress={onClose} hitSlop={12} style={styles.headerButton}>
<X size={22} color={c.textSecondary} />
</Pressable>
) : (
<View style={styles.headerButton} />
)}
</View>
<ScrollView
style={styles.flex}
contentContainerStyle={[styles.content, centered && styles.contentCentered]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{showFooter ? (
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
</View>
) : null}
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
height: componentSizes.headerHeight,
},
headerButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
content: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxxl,
},
contentCentered: { flexGrow: 1, justifyContent: 'center' },
footer: { alignItems: 'center', paddingBottom: spacing.lg, gap: spacing.xs },
footerText: { ...typography.caption, color: c.textMuted },
});
}
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { ChevronRight } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface OptionTileProps {
title: string;
description?: string;
/** Receives the colour the tile's icon renders in. */
renderIcon: (color: string, size: number) => React.ReactNode;
onPress: () => void;
emphasis?: 'primary' | 'default';
disabled?: boolean;
}
/**
* A full-width choice on the sign-in screen: icon, what it does, and what it
* costs you. Used instead of a stack of buttons so each route can say enough
* to be picked without trial and error. Emphasis follows the settings-row
* convention — a tinted icon chip, not a solid card.
*/
export default function OptionTile({
title,
description,
renderIcon,
onPress,
emphasis = 'default',
disabled = false,
}: OptionTileProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isPrimary = emphasis === 'primary';
const iconColor = isPrimary ? c.primary : c.textSecondary;
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={description ? `${title}. ${description}` : title}
style={({ pressed }) => [
styles.tile,
pressed && !disabled && styles.pressed,
disabled && styles.disabled,
]}
>
<View style={styles.icon}>{renderIcon(iconColor, 20)}</View>
<View style={styles.text}>
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
</View>
<ChevronRight size={18} color={c.textMuted} />
</Pressable>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
tile: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
backgroundColor: c.card,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
},
pressed: { backgroundColor: c.surfaceHover },
disabled: { opacity: 0.5 },
icon: { width: 24, alignItems: 'center' },
text: { flex: 1, gap: 2 },
title: { ...typography.bodyMedium, color: c.text },
description: { ...typography.caption, color: c.mutedForeground },
});
}
+98
View File
@@ -0,0 +1,98 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Eye, EyeOff } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface PasswordStepProps {
serverUrl: string;
email: string;
password: string;
onChangeEmail: (value: string) => void;
onChangePassword: (value: string) => void;
onSubmit: () => void;
notice?: { title: string; detail?: string } | null;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
/** The fallback for servers without the browser hand-off — and the only step
* where a password is typed into the app. */
export default function PasswordStep({
serverUrl,
email,
password,
onChangeEmail,
onChangePassword,
onSubmit,
notice,
}: PasswordStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [showPassword, setShowPassword] = React.useState(false);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Sign in to {hostOf(serverUrl)}</Text>
<Text style={styles.subtitle}>
Your password is sent to this server only, and stored in the device keychain.
</Text>
</View>
<Input
label="Email or username"
placeholder="you@example.com"
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
/>
<Input
label="Password"
placeholder="Enter your password"
value={password}
onChangeText={onChangePassword}
autoFocus={Boolean(email)}
secureTextEntry={!showPassword}
autoComplete="current-password"
textContentType="password"
returnKeyType="go"
onSubmitEditing={onSubmit}
rightIcon={
<Pressable
onPress={() => setShowPassword((v) => !v)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
</Pressable>
}
/>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit}>
Sign in
</Button>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
});
}
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Globe, QrCode } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface ServerStepProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onScan: () => void;
/** Set when we got here because discovery came back empty for this domain. */
failedDomain?: string | null;
notice?: { title: string; detail?: string } | null;
}
/**
* Asks for the address people actually know — the one they type in the browser
* to reach their webmail — not the JMAP session endpoint. The scheme and the
* `/.well-known/jmap` suffix are our problem, not theirs.
*/
export default function ServerStep({
value,
onChange,
onSubmit,
onScan,
failedDomain,
notice,
}: ServerStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>What&apos;s your server address?</Text>
<Text style={styles.subtitle}>
{failedDomain
? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.`
: 'Enter the address you use to reach your webmail.'}
</Text>
</View>
<Input
label="Server address"
placeholder="mail.example.com"
value={value}
onChangeText={onChange}
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="go"
onSubmitEditing={onSubmit}
leftIcon={<Globe size={17} color={c.textMuted} />}
/>
<Text style={styles.hint}>Same address you type in the browser. We&apos;ll add https:// for you.</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit}>
Continue
</Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
If you&apos;re not sure, scan a sign-in code instead. It carries the server address.
</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
scanRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.sm },
scanText: { ...typography.caption, color: c.textMuted, flex: 1, lineHeight: 17 },
});
}
+102
View File
@@ -0,0 +1,102 @@
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
// The white mark disappears on the light palette, so pick per theme.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
export type SigningInPhase = 'browser' | 'connecting' | 'pairing';
interface SigningInStepProps {
phase: SigningInPhase;
serverUrl: string | null;
email: string | null;
}
function hostOf(url: string | null): string {
if (!url) return 'your server';
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
function initialsOf(email: string): string {
const local = email.replace(/@.*$/, '');
const parts = local.split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || local[0] || '?').toUpperCase();
}
/**
* Covers the wait while a sign-in is in flight. Naming the server and the
* current stage makes a slow first connection legible instead of a bare
* spinner that could mean anything.
*/
export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
const host = hostOf(serverUrl);
const copy: Record<SigningInPhase, { title: string; detail: string }> = {
browser: {
title: `Opening ${host}`,
detail: 'Finish signing in on the page that just opened. We’ll take it from there.',
},
connecting: { title: `Connecting to ${host}`, detail: 'Setting up your mailbox…' },
pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` },
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
{email ? (
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initialsOf(email)}</Text>
</View>
) : (
<Image
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
style={styles.logo}
resizeMode="contain"
/>
)}
{email ? <Text style={styles.email}>{email}</Text> : null}
<Text style={styles.title}>{copy[phase].title}</Text>
<Text style={styles.detail}>{copy[phase].detail}</Text>
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
</View>
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
gap: spacing.xs,
},
avatar: {
width: componentSizes.avatarLg,
height: componentSizes.avatarLg,
borderRadius: radius.full,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
avatarText: { ...typography.h3, color: c.primaryForeground },
logo: { width: componentSizes.avatarLg, height: componentSizes.avatarLg, marginBottom: spacing.md },
email: { ...typography.caption, color: c.textMuted },
title: { ...typography.h2, color: c.text, textAlign: 'center' },
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
spinner: { marginTop: spacing.xl },
});
}
@@ -45,6 +45,8 @@ beforeEach(() => {
useCalendarStore.setState({
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -130,6 +132,41 @@ describe('calendar-store', () => {
expect(stored[0].accountId).toBe('acc-2');
});
it('classifies calendars whose contents are all Tasks as task lists (#28)', async () => {
// VTODO-only CalDAV collections (Todoist imports, Thunderbird task
// lists) must be flagged so the drawer can keep them out of the
// calendar list. Mixed calendars, calendars with start-less Events,
// and empty calendars all stay ordinary calendars.
mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2', 'startless']);
mockGetEvents.mockResolvedValue([
{ id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-tasks': true } },
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-mixed': true } },
{ id: 't2', '@type': 'Task', title: 'Ship it', calendarIds: { 'cal-mixed': true } },
{ id: 'startless', '@type': 'Event', calendarIds: { 'cal-startless': true } },
]);
await useCalendarStore.getState().fetchEvents(
['cal-tasks', 'cal-mixed', 'cal-startless', 'cal-empty'],
'2026-03-01',
'2026-03-31',
);
expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['cal-tasks']);
expect(useCalendarStore.getState().tasks.map((t) => t.id)).toEqual(['t1', 't2']);
});
it('reclassifies task lists on refetch instead of accumulating stale ids', async () => {
useCalendarStore.setState({ taskOnlyCalendarIds: ['cal-old'] });
mockQueryEvents.mockResolvedValue(['e1']);
mockGetEvents.mockResolvedValue([
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-old': true } },
]);
await useCalendarStore.getState().fetchEvents(['cal-old'], '2026-03-01', '2026-03-31');
expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual([]);
});
it('routes queries by account when own and shared calendars share a raw id', async () => {
// The user's own "default" calendar and a family group's "default"
// calendar collide on the raw JMAP id. The shared one is namespaced
+245 -8
View File
@@ -5,6 +5,7 @@ vi.mock('../../api/email', () => ({
// New helpers used by the incremental-sync path. Default behavior: behave
// like a first-ever load — no prior state, full re-query expected.
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getSharedMailboxes: vi.fn(async () => []),
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxChanges: vi.fn(async () => null),
queryEmails: vi.fn(),
@@ -24,6 +25,8 @@ vi.mock('../../api/email', () => ({
deleteEmail: vi.fn(),
deleteEmails: vi.fn(),
searchEmails: vi.fn(),
unprefixMailboxId: (id: string, accountId?: string) =>
(accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id),
}));
// The mutations now route through the offline outbox. In tests we want the
@@ -31,11 +34,11 @@ vi.mock('../../api/email', () => ({
// op's primitive) immediately so the existing api-call assertions still hold,
// without pulling in network-store / NetInfo.
vi.mock('../outbox-store', async () => {
const api = await import('../../api/email') as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId]);
const api = await import('../../api/email') as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; accountId?: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords, op.accountId);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId);
};
const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise<void>) => {
if (onlineRun) await onlineRun();
@@ -100,6 +103,8 @@ vi.mock('../../api/jmap-client', () => ({
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16,
getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }],
},
}));
@@ -110,7 +115,9 @@ import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
@@ -223,7 +230,7 @@ describe('email-store', () => {
await useEmailStore.getState().markRead('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true);
});
});
@@ -237,7 +244,7 @@ describe('email-store', () => {
await useEmailStore.getState().markUnread('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true });
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined();
});
});
@@ -275,7 +282,7 @@ describe('email-store', () => {
await useEmailStore.getState().moveToMailbox('e1', 'inbox', 'archive');
expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive');
expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive', undefined);
expect(useEmailStore.getState().emails).toHaveLength(1);
expect(useEmailStore.getState().emails[0].id).toBe('e2');
});
@@ -313,6 +320,149 @@ describe('email-store', () => {
});
});
// Issue #6: the "Unread" tri-state filter must reach Email/query as a
// notKeyword condition and force the full re-query path (the incremental
// path only serves the unfiltered base view).
describe('filters (issue #6)', () => {
it('applies the unread filter to the mailbox query', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [],
totalEmails: 0,
searchQuery: '',
filters: { isUnread: true },
mailboxSnapshots: {
'mb-1': { emails: [{ id: 'e9' } as any], total: 1, queryState: 'q-base' },
},
});
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-unread' });
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1' } as any], state: 'em-1' });
await useEmailStore.getState().refreshEmails();
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
expect(mailboxId).toBe('mb-1');
expect(opts.filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
});
// Filter results must not leak into the base-view snapshot.
expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]);
});
});
// Issue #10: after searching, the inbox stayed stuck on the search results.
// Root cause: the incremental Email/queryChanges refresh diffed against the
// on-screen list (search results) instead of the cached base view, then
// wrote the result back into the persisted mailbox snapshot.
describe('search / return to inbox (issue #10)', () => {
const base = [
{ id: 'e1', subject: 'One' } as any,
{ id: 'e2', subject: 'Two' } as any,
];
it('incremental refresh diffs against the snapshot, not on-screen search results', async () => {
// Screen still shows a (stale) search-result list, but searchQuery is
// already '' — the cold-start / just-cleared-search shape.
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: '',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-base',
newQueryState: 'q-2',
total: 2,
removed: [],
added: [],
});
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(state.emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(mockQueryEmails).not.toHaveBeenCalled();
});
it('does not write search results into the mailbox snapshot', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: base,
totalEmails: 2,
searchQuery: 'two',
filters: {},
queryState: 'q-base',
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 1, queryState: 'q-search' });
mockGetEmailsWithState.mockResolvedValue({ list: [base[1]], state: 'em-1' });
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(state.emails).toEqual([base[1]]);
// Base-view cache must survive the search untouched.
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(state.queryState).toBe('q-base');
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
});
it('clearSearchAndFilters restores the cached base view immediately', () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: 'two',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-base',
newQueryState: 'q-2',
total: 2,
removed: [],
added: [],
});
useEmailStore.getState().clearSearchAndFilters();
// Synchronously back on the base view — no waiting for the network.
const state = useEmailStore.getState();
expect(state.searchQuery).toBe('');
expect(state.emails).toEqual(base);
expect(state.totalEmails).toBe(2);
});
it('falls back to a full re-query when the snapshot window is incomplete', async () => {
// A pre-fix install could have a poisoned snapshot: a handful of search
// results stored against the base view's total. It can't be patched
// incrementally — the refresh must rebuild it from the server.
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: '',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: [base[1]], total: 50, queryState: 'q-base' } },
});
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 50, queryState: 'q-new' });
mockGetEmailsWithState.mockResolvedValue({ list: base, state: 'em-2' });
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
expect(state.emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].queryState).toBe('q-new');
});
});
describe('getEmailDetail', () => {
it('should fetch full email', async () => {
const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '<p>Hi</p>' } } };
@@ -323,4 +473,91 @@ describe('email-store', () => {
expect(result).toEqual(fullEmail);
});
});
// ── Shared (Stalwart group account) mailboxes ────────────────────────────
describe('shared mailboxes', () => {
const RIGHTS = {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true,
maySetSeen: true, maySetKeywords: true, mayCreateChild: true,
mayRename: true, mayDelete: true, maySubmit: true,
};
const ownInbox = {
id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0,
totalThreads: 0, unreadThreads: 0, myRights: RIGHTS,
accountId: 'acc-1', isShared: false,
} as any;
const sharedInbox = {
id: 'grp-1:s-inbox', originalId: 's-inbox', name: 'Inbox', role: 'inbox',
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: RIGHTS, accountId: 'grp-1', accountName: 'Support', isShared: true,
} as any;
const sharedTrash = {
...sharedInbox, id: 'grp-1:s-trash', originalId: 's-trash', name: 'Trash', role: 'trash',
} as any;
it('merges shared folders in alongside the own ones', async () => {
mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' });
mockGetSharedMailboxes.mockResolvedValue([sharedInbox]);
await useEmailStore.getState().fetchMailboxes();
expect(useEmailStore.getState().mailboxes).toEqual([ownInbox, sharedInbox]);
});
it('keeps the own folders when a shared account cannot be reached', async () => {
mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' });
mockGetSharedMailboxes.mockRejectedValue(new Error('Session expired'));
await useEmailStore.getState().fetchMailboxes();
const state = useEmailStore.getState();
expect(state.mailboxes).toEqual([ownInbox]);
expect(state.error).toBeNull();
});
it('queries a shared folder against its owning account by raw id', async () => {
useEmailStore.setState({ mailboxes: [ownInbox, sharedInbox] });
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-1' });
mockGetEmailsWithState.mockResolvedValue({
list: [{ id: 'e1', subject: 'Hi' }], state: 'em-1',
});
await useEmailStore.getState().selectMailbox('grp-1:s-inbox');
expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({
accountId: 'grp-1',
filter: { inMailbox: 's-inbox' },
}));
expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1');
// Email state tokens are per-account, so the shared account gets its own.
expect(useEmailStore.getState().emailStates).toEqual({ 'grp-1': 'em-1' });
});
it('deletes from a shared folder against the owning account', async () => {
useEmailStore.setState({
mailboxes: [ownInbox, sharedInbox, sharedTrash],
currentMailboxId: 'grp-1:s-inbox',
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any],
});
await useEmailStore.getState().deleteEmail('e1', 'grp-1:s-trash', 'grp-1:s-inbox');
expect(mockDeleteEmail).toHaveBeenCalledWith('e1', 's-trash', 's-inbox', 'grp-1');
expect(useEmailStore.getState().emails).toHaveLength(0);
});
it('refuses to move a message between accounts', async () => {
useEmailStore.setState({
mailboxes: [ownInbox, sharedInbox],
currentMailboxId: 'grp-1:s-inbox',
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any],
});
await useEmailStore.getState().moveToMailbox('e1', 'grp-1:s-inbox', 'mb-1');
expect(mockMoveEmail).not.toHaveBeenCalled();
const state = useEmailStore.getState();
expect(state.error).toMatch(/same account/);
expect(state.emails).toHaveLength(1);
});
});
});
+4 -4
View File
@@ -79,7 +79,7 @@ describe('applyOrQueue', () => {
it('runs immediately when online with an empty queue', async () => {
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(false);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
@@ -137,9 +137,9 @@ describe('flush', () => {
useNetworkStore.setState({ online: true });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true });
expect(destroyEmails).toHaveBeenCalledWith(['e3']);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }, undefined);
expect(destroyEmails).toHaveBeenCalledWith(['e3'], undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
+25 -1
View File
@@ -65,6 +65,12 @@ export interface CalendarState {
calendars: Calendar[];
events: CalendarEvent[];
tasks: CalendarEvent[];
// Store ids of calendars that hold only Task objects (VTODO-only CalDAV
// collections, e.g. Thunderbird/Todoist task lists). JMAP Calendar/get
// doesn't expose the CalDAV supported-calendar-component-set, so the
// collection kind is inferred from contents: ≥1 task and no events.
// Empty calendars are treated as ordinary event calendars. (#28)
taskOnlyCalendarIds: string[];
hiddenCalendarIds: string[];
loadedRange: LoadedRange | null;
loading: boolean;
@@ -124,6 +130,7 @@ export const useCalendarStore = create<CalendarState>()(
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -203,8 +210,23 @@ export const useCalendarStore = create<CalendarState>()(
return (t === 'Event' || t === undefined) && !!e.start;
});
const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
// Classify VTODO-only task lists from the full (undated) object set:
// a calendar counts as a task list when every object in it is a Task.
// Anything that isn't a Task — including start-less Events — keeps the
// calendar in the ordinary event-calendar bucket.
const eventCalendarIds = new Set<string>();
const taskCalendarIds = new Set<string>();
for (const obj of raw) {
const isTask = (obj as { '@type'?: string })['@type'] === 'Task';
for (const calId of Object.keys(obj.calendarIds || {})) {
(isTask ? taskCalendarIds : eventCalendarIds).add(calId);
}
}
const taskOnlyCalendarIds = [...taskCalendarIds].filter(
(calId) => !eventCalendarIds.has(calId),
);
const events = expandRecurringEvents(onlyEvents, after, before);
set({ events, tasks, loadedRange: { after, before }, loading: false });
set({ events, tasks, taskOnlyCalendarIds, loadedRange: { after, before }, loading: false });
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
@@ -434,6 +456,7 @@ export const useCalendarStore = create<CalendarState>()(
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
loadedRange: null,
loading: false,
error: null,
@@ -449,6 +472,7 @@ export const useCalendarStore = create<CalendarState>()(
calendars: state.calendars,
events: state.events,
tasks: state.tasks,
taskOnlyCalendarIds: state.taskOnlyCalendarIds,
loadedRange: state.loadedRange,
}),
},
+456 -126
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -34,10 +34,13 @@ function storageKey(accountId: string): string {
return `${KEY_PREFIX}${accountId}`;
}
// `accountId` is the JMAP account the message lives under. Absent for the
// user's own mail (the client's primary account); set when the message belongs
// to a shared/group account, so replay after a reconnect still targets it.
export type OutboxOp =
| { kind: 'keywords'; emailId: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string };
| { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string; accountId?: string };
export interface OutboxEntry {
id: string;
@@ -85,11 +88,11 @@ async function load(accountId: string): Promise<OutboxEntry[]> {
async function runOp(op: OutboxOp): Promise<void> {
switch (op.kind) {
case 'keywords':
return setEmailKeywords(op.emailId, op.keywords);
return setEmailKeywords(op.emailId, op.keywords, op.accountId);
case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds);
return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
case 'destroy':
return destroyEmails([op.emailId]);
return destroyEmails([op.emailId], op.accountId);
}
}
@@ -136,20 +139,23 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
return;
}
let entries = [...get().entries];
const { emailId } = op;
// Message identity is (account, id): JMAP ids are only unique within an
// account, and the queue can hold ops for shared accounts alongside own.
const sameMessage = (other: OutboxOp) =>
other.emailId === op.emailId && other.accountId === op.accountId;
if (op.kind === 'destroy') {
// Destroy is terminal — drop any pending edits for this message and
// append the destroy so it runs last.
entries = entries.filter((e) => e.op.emailId !== emailId);
entries = entries.filter((e) => !sameMessage(e.op));
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
} else {
// A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => e.op.emailId === emailId && e.op.kind === 'destroy')) return;
if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same kind for this message
// (full-state replace makes the latest one authoritative). Keep its
// position so creation order is preserved for replay.
const idx = entries.findIndex((e) => e.op.emailId === emailId && e.op.kind === op.kind);
const idx = entries.findIndex((e) => sameMessage(e.op) && e.op.kind === op.kind);
if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined };
} else {
+7
View File
@@ -4,6 +4,7 @@ import Constants from 'expo-constants';
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
import { isNewer } from '../lib/version-compare';
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -80,6 +81,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
},
checkNow: async (opts) => {
// On platforms without sideloading there is nothing actionable to report,
// so skip the GitHub round-trip entirely rather than caching a release the
// user can never install.
if (!supportsSideloadUpdates) return;
const s = get();
if (s.checking) return;
const now = Date.now();
@@ -104,6 +109,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
},
installLatest: async () => {
if (!supportsSideloadUpdates) return;
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
@@ -142,6 +148,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
hasUpdate: () => {
if (!supportsSideloadUpdates) return false;
const s = get();
if (!s.cachedLatest) return false;
const current = Constants.expoConfig?.version ?? '0.0.0';