mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: add UnifiedPush as an alternative to FCM for push notifications
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
@@ -464,10 +465,16 @@ export default function App() {
|
||||
const unsubscribe = addTokenRefreshListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
|
||||
// out a new endpoint, so re-register it with the relay.
|
||||
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
unsubscribeUp();
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
@@ -171,6 +171,12 @@ dependencies {
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// UnifiedPush - Google-free push via a user-chosen distributor app such
|
||||
// as ntfy. Coexists with FCM; the user picks the transport in settings.
|
||||
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
|
||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -9,6 +9,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkUnifiedPushModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
startHeadlessTask(data)
|
||||
startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
val params = Arguments.createMap().apply {
|
||||
@@ -42,26 +42,28 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
private fun startHeadlessTask(data: Map<String, String>) {
|
||||
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
applicationContext.startService(intent)
|
||||
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "bulwark_mail"
|
||||
|
||||
// Shared with BulwarkUnifiedPushService - a push arriving over either
|
||||
// transport is dispatched the same way.
|
||||
fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
fun startHeadlessTask(context: Context, data: Map<String, String>) {
|
||||
val intent = Intent(context, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
context.startService(intent)
|
||||
HeadlessJsTaskService.acquireWakeLockNow(context)
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
// JS-facing surface for UnifiedPush distributor management. Events from the
|
||||
// distributor (endpoint changes, incoming messages) arrive through
|
||||
// BulwarkUnifiedPushService, which emits them on the shared device-event
|
||||
// channel.
|
||||
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkUnifiedPush"
|
||||
|
||||
@ReactMethod
|
||||
fun getDistributors(promise: Promise) {
|
||||
try {
|
||||
val array = Arguments.createArray()
|
||||
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
|
||||
promise.resolve(array)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributors_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSavedDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getAckDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun saveDistributor(distributor: String, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_save_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the saved distributor for an endpoint. The endpoint itself arrives
|
||||
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
|
||||
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
|
||||
// relay's VAPID public key - optional, some distributors use it to
|
||||
// restrict who may push.
|
||||
@ReactMethod
|
||||
fun register(vapid: String?, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.register(
|
||||
reactApplicationContext,
|
||||
messageForDistributor = null,
|
||||
vapid = vapid?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_register_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun unregister(promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.unregister(reactApplicationContext)
|
||||
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_unregister_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getEndpoint(promise: Promise) {
|
||||
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
|
||||
if (stored == null) {
|
||||
promise.resolve(null)
|
||||
return
|
||||
}
|
||||
val (url, p256dh, auth) = stored
|
||||
val map = Arguments.createMap().apply {
|
||||
putString("url", url)
|
||||
putString("p256dh", p256dh)
|
||||
putString("auth", auth)
|
||||
}
|
||||
promise.resolve(map)
|
||||
}
|
||||
|
||||
// NativeEventEmitter required no-ops.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
|
||||
// The relay sends the same content-blind ForwardPayload it sends over FCM -
|
||||
// as encrypted Web Push when the connector provided RFC 8291 keys (the
|
||||
// connector decrypts before this service sees it) - so an incoming message is
|
||||
// dispatched exactly like an FCM data message: headless task when the app is
|
||||
// backgrounded, `fcm:message` device event for the live JS instance.
|
||||
class BulwarkUnifiedPushService : PushService() {
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
saveEndpoint(this, endpoint)
|
||||
val params = Arguments.createMap().apply {
|
||||
putString("url", endpoint.url)
|
||||
putString("p256dh", endpoint.pubKeySet?.pubKey)
|
||||
putString("auth", endpoint.pubKeySet?.auth)
|
||||
}
|
||||
BulwarkFcmModule.emit("up:newEndpoint", params)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
BulwarkMessagingService.ensureChannel(this)
|
||||
val data = parsePayload(message.content) ?: return
|
||||
|
||||
if (!BulwarkMessagingService.isAppInForeground()) {
|
||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
// Same event name and shape as the FCM path so the foreground JS
|
||||
// handler (App.tsx addMessageListener) works transport-agnostically.
|
||||
val params = Arguments.createMap().apply {
|
||||
val dataMap = Arguments.createMap()
|
||||
for ((k, v) in data) dataMap.putString(k, v)
|
||||
putMap("data", dataMap)
|
||||
}
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
clearEndpoint(this)
|
||||
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
val params = Arguments.createMap().apply { putString("reason", reason.name) }
|
||||
BulwarkFcmModule.emit("up:registrationFailed", params)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "bulwark_unifiedpush"
|
||||
private const val KEY_URL = "endpointUrl"
|
||||
private const val KEY_P256DH = "endpointP256dh"
|
||||
private const val KEY_AUTH = "endpointAuth"
|
||||
|
||||
// The endpoint arrives asynchronously (possibly while no JS instance
|
||||
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
|
||||
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_URL, endpoint.url)
|
||||
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
|
||||
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val url = prefs.getString(KEY_URL, null) ?: return null
|
||||
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
|
||||
}
|
||||
|
||||
fun clearEndpoint(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
// The relay's ForwardPayload JSON, flattened to the string map the
|
||||
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
|
||||
// and foreground handler can stay transport-blind.
|
||||
fun parsePayload(content: ByteArray): Map<String, String>? = try {
|
||||
val json = JSONObject(String(content, Charsets.UTF_8))
|
||||
buildMap {
|
||||
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
|
||||
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
|
||||
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
|
||||
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
|
||||
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
# Native app audit, 2026-09-22
|
||||
|
||||
Bugs, performance and webmail gaps in the React Native app, as a TODO list. Every item carries
|
||||
severity, effort, a `file:line` in this repo (paths relative to `repos/react-native`), the webmail
|
||||
file to match where there is one (paths relative to the webmail repo root), a repro or measurement,
|
||||
and whether it was **verified** (reproduced on a device, on the wire or by measurement) or is
|
||||
**suspected** (code reading only).
|
||||
|
||||
Severity: P0 crash / data loss / security, P1 broken feature, P2 degraded, P3 polish.
|
||||
Effort: S (hours), M (a day or two), L (a week or more).
|
||||
|
||||
## Scope and method
|
||||
|
||||
- **Code:** local `main` at `6c82130` (0.1.62) plus the uncommitted UnifiedPush work in the tree
|
||||
(push files only). GitHub `main` is 2 commits ahead (`c2584c2`, `5b8c435`, PR #53: tappable
|
||||
links in event descriptions); nothing else differs. Webmail reference: `main` at `9ef7068b`.
|
||||
- **Server:** a throwaway Stalwart **0.16.23** in local Docker (`stw-native-audit`, stopped, kept).
|
||||
The 1blu test host (`stw-test19`) was unusable: its Docker daemon was stopped and disabled on
|
||||
2026-09-19. Accounts: `usera`, `userb`, and a `team` group that `usera` belongs to.
|
||||
- Seed: Inbox 653, Archive 300, Sent 162, Projects/Alpha 40, Projects/Beta 25, Receipts 30,
|
||||
Junk 15; team Inbox 80.
|
||||
- Message types: threads, 80 heavy HTML newsletters (half HTML-only), attachments, an iMIP
|
||||
invite, an embedded message, an inline cid image, RTL mail, and a read-receipt request.
|
||||
- Also 27 events (one weekly recurring), 150 contacts (two with photos), and 515 file nodes
|
||||
(folders created after the 500th).
|
||||
- **Device:** Android emulator `Bulwark_Pixel7_API35` (x86_64, WHPX) running a release APK built
|
||||
from the tree with a temporary cleartext flag (reverted afterwards).
|
||||
- Driven with `uiautomator` and `input`.
|
||||
- Every request went through a logging proxy that recorded JMAP methods, arguments and
|
||||
set-errors.
|
||||
- UI timings come from `screenrecord` frames diffed with ffmpeg. They are emulator numbers:
|
||||
compare them with each other, not with a phone.
|
||||
- **Static review:** covered selectors, rendering, request patterns, bundle and build, and the
|
||||
webmail diff since 2026-08-29 (142 non-merge commits). Every item below that says **verified**
|
||||
was re-checked on the device, on the wire, or by direct measurement (APK and bundle inspection).
|
||||
|
||||
### Test suite status
|
||||
|
||||
- `npx tsc --noEmit`: clean on the tree and on a clean `git archive` of HEAD.
|
||||
- `npx vitest run`: green. 77 files and 970 tests pass on the tree; HEAD passes too, apart from
|
||||
the load-related timeout below.
|
||||
- The note "red on main, netinfo not mocked" is stale. `b84d4d8` (2026-08-29) mocks netinfo
|
||||
in `src/test-setup.ts`.
|
||||
- The only failure seen: `src/i18n/__tests__/keys.test.ts:45` timed out once, taking 16.8 s
|
||||
against the 5 s default while gradle was building. Run alone it takes 0.4 s. It walks `src/`
|
||||
synchronously, so give it a longer timeout.
|
||||
- Live suite (`src/api/__tests__/live-stalwart.test.ts`): 14/14 pass against 0.16.23. All three
|
||||
Stalwart quirks still exist in 0.16.23 and are handled on the paths the suite covers:
|
||||
- the third `Email/set` response on a send;
|
||||
- the same `partId` in `textBody` and `htmlBody`;
|
||||
- `urn:stalwart:jmap` appearing only in `accountCapabilities`.
|
||||
- The zustand selector sweep found no crash-class selectors: 685 call sites, none returns a
|
||||
fresh object or array.
|
||||
- Open issue #50 (crash opening any mail) did not reproduce in about 20 opens, including mail
|
||||
with attachments, an invitation, a 586 KB body and RTL mail.
|
||||
|
||||
## Summary: fix these ten first
|
||||
|
||||
1. [ ] **P0 · S:** read receipts carry attacker-controlled headers and are sent for messages the user
|
||||
never opened ([S1](#s1), [B7](#b7)).
|
||||
2. [ ] **P0 · S–M:** published release APKs are signed with the public React Native debug key
|
||||
([S2](#s2)).
|
||||
3. [ ] **P0 · S–M:** viewer actions on a shared/group message hit the user's own account and can
|
||||
delete an unrelated message ([B3](#b3)).
|
||||
4. [ ] **P0 · S:** opening, starring or tagging replaces the whole keyword map; stars and tags are
|
||||
erased ([B4](#b4)).
|
||||
5. [ ] **P1 · S:** the viewer switches to another message when new mail arrives, and its buttons act
|
||||
on the message you are not looking at ([B5](#b5)).
|
||||
6. [ ] **P1 · S–M:** messages disappear from the list after a background refresh until you pull to
|
||||
refresh ([B6](#b6)).
|
||||
7. [ ] **P1 · S:** scheduled send, the undo-send delay and the Scheduled folder are dead on every
|
||||
Stalwart server (#57, [B8](#b8)).
|
||||
8. [ ] **P1 · S–M:** sharing a file into the app always fails, and `SENDTO`/`+`-address `mailto:` links
|
||||
open nothing ([B9](#b9), [B10](#b10), [B11](#b11)).
|
||||
9. [ ] **P1 · M:** opening a message takes 1.7–1.9 s and the layout jumps three times ([PF1](#pf1)).
|
||||
10. [ ] **P1 · M:** cold start takes 3.6–4.1 s to a settled inbox and is JS-bound ([PF2](#pf2)).
|
||||
|
||||
**Why these ten:**
|
||||
- **1–2:** security holes that can be exploited today without any user mistake.
|
||||
- **3–6:** the app silently changes, hides or acts on the wrong mail. When users call the app
|
||||
"buggy", this is the class that loses their trust, and all four were reproduced in minutes.
|
||||
- **7–8:** core flows that fail for every Stalwart user or every share/`mailto:` user.
|
||||
- **9–10:** the latency users feel on every launch and every tap, the "slow" in "buggy and slow".
|
||||
|
||||
Everything else below is narrower (file trees past 500 nodes; tasks, which are off by default),
|
||||
suspected, or cosmetic.
|
||||
|
||||
## Bugs
|
||||
|
||||
### Crashes
|
||||
|
||||
- [ ] <a id="b1"></a>**B1 · P1 · M: session restore can hang forever when a persisted cache fails
|
||||
to load.**
|
||||
- Where: `src/stores/auth-store.ts:23-31`, `src/stores/auth-store.ts:603-608`,
|
||||
`src/stores/settings-store.ts:423`.
|
||||
- Why:
|
||||
- AsyncStorage's Android database is capped at 6 MB, because `android/gradle.properties`
|
||||
has no `AsyncStorage_db_size_in_MB`. Rows over about 2 MB can't be read back.
|
||||
- The offline cache defaults to 50 MB. The email snapshot has no cap and stores the current
|
||||
folder twice.
|
||||
- zustand only marks a store hydrated when the read succeeds, and `waitForHydration` has
|
||||
no timeout.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b2"></a>**B2 · P3 · S: the composer's WebView renderer process dies when the composer
|
||||
closes.**
|
||||
- Evidence: logcat shows `aw_browser_terminator … Renderer process (11521) crash detected
|
||||
(code -1)` at 18:42:55 and again (11663) at 18:45:42, both on composer close.
|
||||
- The app survives, but the cause is unknown.
|
||||
- **Verified** (log only).
|
||||
|
||||
### Data correctness
|
||||
|
||||
- [ ] <a id="b3"></a>**B3 · P0 · S–M: viewer actions use the open folder's account instead of the
|
||||
message's.**
|
||||
- Where: `src/screens/EmailThreadScreen.tsx:107-113` (owner falls back to the list folder),
|
||||
`src/screens/EmailThreadScreen.tsx:355-358` (delete), `:388-414` (archive, move, spam),
|
||||
`src/stores/email-store.ts:1194-1197`.
|
||||
- Web: `stores/email-store.ts` (`resolveUnstampedEmailAccountId`, rows stamped with their account).
|
||||
- Repro:
|
||||
- From the Unified Inbox, open team message `keaaaacr` and tap Delete. There is no confirmation.
|
||||
- `Email/set` goes to account `c` and moves usera's own, unrelated "Security audit #18" (same
|
||||
id, since Stalwart ids are only unique per account) to Trash.
|
||||
- The team message stays where it was.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b4"></a>**B4 · P0 · S: mark-read, star, pin and tag replace the whole `keywords`
|
||||
map.**
|
||||
- Where: `src/stores/email-store.ts:963-983`, `src/api/email.ts:602-609`. Outbox replay does
|
||||
the same.
|
||||
- Web: `lib/jmap/client.ts` (`keywords/$seen` pointer patch, `batchMarkAsRead`). RN already has
|
||||
`patchKeywordsForEmails` at `src/api/email.ts:612`.
|
||||
- Repro 1 (message not in the loaded list):
|
||||
- Open a team message that has `$flagged` and `$label:work` from the Unified Inbox.
|
||||
- The app sends `update {keywords: {"$seen": true}}`, and the server keeps only `$seen`. The
|
||||
star and the tag are gone, while the viewer still shows the star.
|
||||
- Repro 2 (message in the list): Star sent the full map `{$notjunk, $seen, $flagged}`. That
|
||||
reverts any keyword another client changed since the list loaded.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b5"></a>**B5 · P1 · S: the viewer pager follows the live list, so new mail shifts the
|
||||
page under the user.**
|
||||
- Where: `src/screens/EmailThreadScreen.tsx:81-102`, `:639`.
|
||||
- Repro:
|
||||
- Open "Quarterly numbers" (row 2) and deliver 2 new messages.
|
||||
- The screen switches by itself to "Drift probe one".
|
||||
- Tapping Star then flags "Quarterly numbers", which is no longer displayed. Delete and
|
||||
Archive use the same `activeEmailId`.
|
||||
- Fix: snapshot the id list when the viewer opens.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b6"></a>**B6 · P1 · S–M: messages vanish after a delta refresh.**
|
||||
- Where: `src/stores/email-store.ts:1953-1990` and `:2058-2060`.
|
||||
- Web: `stores/email-store.ts` (`applyEmailDeltaNow`, which runs `Email/changes` against the
|
||||
list's own state).
|
||||
- Why:
|
||||
- `inMailbox` is a mutable filter. Stalwart therefore reports every updated id as removed and
|
||||
re-added.
|
||||
- RN drops those ids and skips re-fetching them because they "already exist".
|
||||
- The account-wide `emailState` token is deleted whenever a query returns no ids.
|
||||
- Repro:
|
||||
- Open the empty "Projects" folder, go back to Inbox, and flag "Please confirm receipt" from
|
||||
another client.
|
||||
- The refresh sends only `Email/queryChanges`, and the row disappears.
|
||||
- A pull-to-refresh (a full query) brings it back.
|
||||
- Before the empty-folder visit the same test worked (`queryChanges` → `changes` → `get`).
|
||||
- **Verified.**
|
||||
- [ ] <a id="b7"></a>**B7 · P1 · S: with "Always send", read receipts go out for messages that were
|
||||
never displayed, and a failed one leaves junk in Sent.**
|
||||
- Where: `src/components/email/ReadReceiptBanner.tsx:92-101` (mounted in the neighbouring pages
|
||||
the pager pre-renders, `src/screens/EmailThreadScreen.tsx:641` `windowSize={3}`),
|
||||
`src/api/email.ts:1456-1486`.
|
||||
- Repro:
|
||||
- Open "Invitation: Design review". The app uploads, imports and submits an MDN for the next
|
||||
message, "Please confirm receipt", which the user never saw.
|
||||
- Stalwart rejected this submission (a `.example` address), but the imported "Read: Please
|
||||
confirm receipt" stays in Sent. `$mdnsent` is not set and a failed auto-send clears its guard
|
||||
(`autoRef.current = null`), so it can run again and import another copy.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b8"></a>**B8 · P1 · S: scheduled send and the undo-send delay are off on Stalwart
|
||||
(#57).**
|
||||
- Where: `src/api/jmap-client.ts:973-999` reads `session.capabilities[submission]`. Stalwart
|
||||
only puts `maxDelayedSend` and FUTURERELEASE in `accountCapabilities`. `:919-925` (attachment
|
||||
size cap) has the same problem.
|
||||
- Web: `lib/jmap/client.ts` (reads `accountCapabilities`).
|
||||
- Repro: the account advertises `maxDelayedSend: 2592000` with FUTURERELEASE. Composer clock
|
||||
→ "In 1 hour" → "Scheduling unavailable: This mail server does not support scheduled send".
|
||||
- The Scheduled screen and the undo delay use the same check.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b9"></a>**B9 · P1 · S: files shared into the app never upload.**
|
||||
- Where: `src/api/blob.ts:99` passes the `content://` URI to `createUploadTask`, which
|
||||
requires a file path. `src/lib/share-intent.ts:40` names the file after the URI's last
|
||||
segment.
|
||||
- Repro: `am start -a SEND -t image/png --eu android.intent.extra.STREAM content://media/…/1000000027`
|
||||
→ alert "Failed to upload 1000000027 … IOException: Directory for
|
||||
'/external/images/media/1000000027' doesn't exist".
|
||||
- **Verified.**
|
||||
- [ ] <a id="b10"></a>**B10 · P1 · S: `ACTION_SENDTO mailto:` opens the inbox, not the composer.**
|
||||
- Where: `android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt:25` only
|
||||
handles SEND and SEND_MULTIPLE. RN's `Linking` forwards only VIEW. The manifest registers
|
||||
SENDTO anyway.
|
||||
- Repro: `am start -a android.intent.action.SENDTO -d mailto:bob@partner.example` → no
|
||||
composer. The same URI with VIEW works.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b11"></a>**B11 · P2 · S: `mailto:` links with a `+` address do nothing.**
|
||||
- Where: `src/lib/mailto.ts:21` turns `+` into a space, validation then fails, and the parser
|
||||
returns null.
|
||||
- Web: `lib/validation.ts`.
|
||||
- Repro: `mailto:alice+news@partner.example` → no composer.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b12"></a>**B12 · P1 · S: tasks can't be completed (#958).**
|
||||
- Where: `src/stores/calendar-store.ts:710-719` sends `progressUpdated`. The checkbox at
|
||||
`src/components/calendar/TasksSheet.tsx:442` swallows the error.
|
||||
- Web: `6b30e91c`, `stores/task-store.ts`.
|
||||
- Repro: tap the task checkbox → `notUpdated: invalidProperties ["progressUpdated"]`. The task
|
||||
stays `needs-action` and the UI shows no error.
|
||||
- Tasks are off by default (`src/stores/settings-store.ts:381`), which limits the reach.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b13"></a>**B13 · P1 · M: the Files tab stops at 500 nodes (#1069).**
|
||||
- Where: `src/api/files.ts:98-112` (`FileNode/get ids:null`, which Stalwart silently caps at
|
||||
`maxObjectsInGet`). The same code runs at `:133-167`.
|
||||
- Web: `lib/jmap/client.ts` (`fetchAllFileNodes`, `9893b0a8`).
|
||||
- Repro: with 510 root files created before the folders, `file-0490`–`0499` are listed, while
|
||||
`file-0500`–`0509` and the Documents and Photos folders (and their files) never appear.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b14"></a>**B14 · P2 · S: bodies over 512 KB render cut off with no notice (#884).**
|
||||
- Where: `src/api/email.ts:493-499` (`maxBodyValueBytes: 512000`, `isTruncated` never read).
|
||||
- Web: `lib/jmap/client.ts` (`2cf7427e`, refetch of truncated bodies).
|
||||
- Repro: for a 586 KB HTML report, the server returns `isTruncated: true` with 59 characters.
|
||||
The viewer shows the heading, one line and "…"; the list preview shows the full text.
|
||||
- **Verified.**
|
||||
- [ ] <a id="b15"></a>**B15 · P1 · S: a redirect that drops the Authorization header signs the user
|
||||
out on launch.**
|
||||
- Where: `src/api/jmap-client.ts:737-763`. The retry keys on `response.redirected`, which RN's
|
||||
fetch never sets. An empty session then throws `AuthenticationError`, and
|
||||
`src/stores/auth-store.ts:677-683` evicts the account.
|
||||
- Web: `40e38d16` (#892).
|
||||
- Only affects servers behind a redirecting proxy; direct Stalwart is fine.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b16"></a>**B16 · P1 · S: "Save draft" discards the text when identities didn't load
|
||||
(offline) or there is no Drafts folder.**
|
||||
- Where: `src/screens/ComposeScreen.tsx:1208` returns early; `:1288-1290` closes anyway.
|
||||
- On iOS, the `beforeRemove` listener at `:1340` doesn't stop a native swipe-down; that needs
|
||||
`usePreventRemove`.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b17"></a>**B17 · P1 · S: calendar reminders get cancelled when the calendar view
|
||||
moves.**
|
||||
- Where: `src/lib/calendar-notifications.ts:116-134` rebuilds the reminder set from the
|
||||
windowed `events` and cancels everything else.
|
||||
- Viewing another month drops all pending reminders. Nothing reschedules them in a session
|
||||
that never opens the Calendar tab.
|
||||
- There is no `setNotificationHandler`, so reminders don't show while the app is in the
|
||||
foreground.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b18"></a>**B18 · P1 · S: push can end up permanently off for users with ACL-shared
|
||||
mailboxes.**
|
||||
- Where: `src/lib/push-notifications.ts` (HEAD `:223-229` builds the `emailPush` map from
|
||||
every account with mailboxes).
|
||||
- Stalwart rejects the whole map with `forbidden` for accounts the user doesn't own and isn't
|
||||
a group member of. The refresh path then destroys the working subscription and the recreate
|
||||
fails the same way.
|
||||
- Web: `lib/web-push.ts:86-94` has the same code.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b19"></a>**B19 · P2 · S: "Reschedule" drops Cc and Bcc.**
|
||||
- Where: `src/api/email.ts:1393` rebuilds recipients from `scheduled.to`;
|
||||
`src/screens/ScheduledScreen.tsx:140`.
|
||||
- Web: `lib/jmap/client.ts` (reuses the envelope).
|
||||
- Unreachable on Stalwart until B8 is fixed.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b20"></a>**B20 · P2 · S: "Load more" skips messages in Unread and Starred views.**
|
||||
- Where: `src/stores/email-store.ts:776` uses `position: emails.length`, which counts rows that
|
||||
were kept after they stopped matching the query.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b21"></a>**B21 · P2 · S: the event editor wipes what the user typed when a calendar push
|
||||
or the alias lookup arrives.**
|
||||
- Where: `src/components/calendar/EventModal.tsx:231-273` (the reset effect depends on
|
||||
`calendars` and `currentUserEmails`).
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b22"></a>**B22 · P2 · S: an invitation's Accept sends no RSVP when the event is outside
|
||||
the loaded calendar window, yet says "Added to calendar".**
|
||||
- Where: `src/components/email/CalendarInvitationBanner.tsx:177-188`.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b23"></a>**B23 · P2 · S: "Send failed" can be shown for mail that was sent, so a retry
|
||||
sends it twice.**
|
||||
- Where: `src/api/email.ts:1178-1181` throws on any `error` response. Stalwart appends one when
|
||||
`onSuccessUpdateEmail` fails after a successful submission.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="b24"></a>**B24 · P3 · S: some writes report success when the server refused them.**
|
||||
- Where: `src/api/files.ts:199` and `:225` (rename, move, delete read `[0][1]` without checking
|
||||
for an `error` response); `src/api/calendar.ts:449-459` ignores `notDestroyed`;
|
||||
`src/api/push.ts:14,82,105`.
|
||||
- **Suspected.**
|
||||
|
||||
### UI glitches
|
||||
|
||||
- [ ] <a id="u1"></a>**U1 · P2 · S: the cold-start sequence shows a blank screen and jumps twice.**
|
||||
- Where: `App.tsx:613-620`; `AppTheme` has no `windowBackground`; `PushOnboardingPrompt`.
|
||||
- Frames:
|
||||
- The dark splash is followed by about 1 s of blank white.
|
||||
- Rows appear without a header, with the search bar under the status bar.
|
||||
- The header then pops in and pushes the list down.
|
||||
- The notification card lands over the status bar and pushes the list down again.
|
||||
- **Verified** (`screenrecord`).
|
||||
- [ ] <a id="u2"></a>**U2 · P2 · S: opening a message shows a skeleton, then a 120 px strip of body,
|
||||
then the full body.**
|
||||
- Where: `src/components/EmailBodyView.tsx:588-591` (the height resets to 120 on every
|
||||
message).
|
||||
- The header data is already in the list row but isn't used. The quick-reply box jumps as the
|
||||
WebView grows. See PF1.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u3"></a>**U3 · P2 · S: sender image and table widths are overridden.**
|
||||
- Where: `src/lib/email-html.ts:452-454`. `img { max-width: 100% !important }` beats inline
|
||||
`max-width`.
|
||||
- Web: `68341a0b`, `ab4bf91b` (#790).
|
||||
- Repro: an image with `max-width:200px; max-height:134px; width:100%` renders full width and
|
||||
squashed about 1.9×; a table with `max-width:300px` spans the pane.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u4"></a>**U4 · P2 · S: RTL mail renders left-to-right.**
|
||||
- Where: `src/lib/email-html.ts:627` and `:686` (no `dir="auto"`).
|
||||
- Web: `e2990da5`, `48048f16`.
|
||||
- Repro: an Arabic plain-text body is left-aligned and its sentence-final period sits on the
|
||||
wrong side.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u5"></a>**U5 · P3 · S: list previews keep the server's newlines.**
|
||||
- Rows show a dangling "…" line and have uneven heights ("Q3 revenue is up 12 %.\n\n…").
|
||||
- A subject that contains CR/LF also renders over several lines in the list and the viewer.
|
||||
- Where: `src/screens/EmailListScreen.tsx` row.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u6"></a>**U6 · P3 · S: the schedule sheet offers presets, then refuses them.**
|
||||
- Where: `src/screens/ComposeScreen.tsx:1846-1851`. The sheet should hide the presets.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u7"></a>**U7 · P3 · S: Back in Files selection mode switches to the Mail tab instead of
|
||||
clearing the selection.**
|
||||
- **Verified.**
|
||||
- [ ] <a id="u8"></a>**U8 · P3 · M: icon buttons and switches have no accessibility label.**
|
||||
- Unlabeled controls:
|
||||
- mail list: hamburger, select-all, filter;
|
||||
- compose FAB;
|
||||
- composer: close, attach, schedule;
|
||||
- every calendar header button;
|
||||
- per-file menu, settings switches, task checkbox.
|
||||
- Web: `4947c6c7`.
|
||||
- Repro: `uiautomator dump` shows an empty `content-desc` on all of these.
|
||||
- **Verified.**
|
||||
- [ ] <a id="u9"></a>**U9 · P3 · S: Android notifications use the stock envelope as their small icon.**
|
||||
- Where: `android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt:128` and
|
||||
`:181` (`android.R.drawable.ic_dialog_email`).
|
||||
- **Suspected.**
|
||||
|
||||
## Performance
|
||||
|
||||
All numbers come from the x86_64 emulator with a release build and a local server. Use them to
|
||||
compare against each other, not as phone numbers.
|
||||
|
||||
- [ ] <a id="pf1"></a>**PF1 · P1 · M: opening a message takes 1.65–1.87 s from tap to a settled
|
||||
screen.**
|
||||
- Where: `src/screens/EmailThreadScreen.tsx:161-199` (a network-first detail fetch even for
|
||||
cached ids, plus a thread fetch with full bodies), `:641` (the pager mounts 3 panes, each
|
||||
with its own WebView), `src/components/EmailBodyView.tsx:588-591` (the body is sanitized
|
||||
twice per mount).
|
||||
- Web: `stores/email-store.ts` and `mail-app.tsx` on `perf/fastest-mail` (header painted from the
|
||||
row, prefetch on press).
|
||||
- Measured:
|
||||
- Plain 1 KB message 1.87 s; newsletter 1.65 s; two-message thread 1.72 s.
|
||||
- Plain open, step by step: skeleton; header at +0.41 s; body at +1.02 s (clipped); resize
|
||||
and quick-reply jump at +1.69 s.
|
||||
- One open from the Unified Inbox made 12 requests: the body twice, the thread twice,
|
||||
`Identity/get`, and a forbidden `x:Account/get`.
|
||||
- **Verified** (numbers and requests); causes suspected.
|
||||
- [ ] <a id="pf2"></a>**PF2 · P1 · M: cold start takes 3.6 / 3.8 / 4.1 s from launch to a settled
|
||||
inbox (3 runs).**
|
||||
- Measured:
|
||||
- `am start` reports 451–564 ms to the first frame (the splash).
|
||||
- Cached rows at about +1.2 s.
|
||||
- The first network request at +1.84 s (`GET /jmap/session`).
|
||||
- Then 700 ms of JS work before the 14-request delta burst.
|
||||
- Network is not the bottleneck: every request finishes within 0.7 s.
|
||||
- Causes:
|
||||
- All 27 locale catalogs are imported and built at startup (`src/i18n/index.ts:11-37`), 36 %
|
||||
of the 9.13 MB Hermes bundle.
|
||||
- `lucide-react-native` is imported through its barrel in 93 files (e.g. `App.tsx:8`), which
|
||||
evaluates all 1,694 icons (1.08 MB, 12 %).
|
||||
- The date-fns locale barrel (`src/lib/calendar-locale.ts:2-13`) adds 830 KB.
|
||||
- 3,586 of 3,993 modules run before the first frame.
|
||||
- The session fetch waits for four serial AsyncStorage hydrations
|
||||
(`src/stores/auth-store.ts:603-608`).
|
||||
- The headless push task imports the whole app (`index.ts:10`).
|
||||
- **Verified** (device timings; bundle shares measured by rebuilding the bundle with hermesc and
|
||||
removing module groups one at a time).
|
||||
- [ ] <a id="pf3"></a>**PF3 · P2 · M: the mail list is heavier than the emulator floor.**
|
||||
- Measured over already-loaded rows (14 flings): p50 21 ms, p90 32 ms, p95 38 ms, p99 57 ms.
|
||||
The same flings in the system Settings list: 17 / 18 / 20 / 36 ms.
|
||||
- Causes:
|
||||
- Inline `onEndReached` and `onRefresh`, and no `strictMode`, so FlatList re-runs every
|
||||
`renderItem` (`src/screens/EmailListScreen.tsx:1285-1288`).
|
||||
- SwipeableRow is not memoized, creates 4 new interpolations per render, and keeps both swipe
|
||||
bands mounted in every row (`src/components/SwipeableRow.tsx:252-297`).
|
||||
- Row dates build two `Intl` formatters per row (`src/lib/date-format.ts:75-97`).
|
||||
- The PanResponder drag runs on the JS thread (`src/components/SwipeableRow.tsx:170-172`).
|
||||
- **Verified** (numbers); causes suspected.
|
||||
- [ ] <a id="pf4"></a>**PF4 · P2 · S: hidden screens keep rendering.**
|
||||
- Where: there is no `enableFreeze` or `freezeOnBlur` (`App.tsx:634`, `index.ts`).
|
||||
- The tab navigator subscribes to the whole `mailboxes` array to compute one badge
|
||||
(`App.tsx:127`), and `src/stores/email-store.ts:1855` sets a new array on every mailbox
|
||||
fetch.
|
||||
- So every refresh also re-renders the mail list under an open message.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="pf5"></a>**PF5 · P2 · S–M: every store write re-serializes the whole persisted cache.**
|
||||
- Where: `src/stores/email-store.ts:1723-1731` (the email cache, with the current folder
|
||||
stored twice), `src/stores/contacts-store.ts:824-829`,
|
||||
`src/stores/calendar-store.ts:769-775`. This includes `loading` flips; there is no debounce.
|
||||
- Opening one unread message causes about 5 full writes.
|
||||
- **Suspected.** The stringify cost was measured off-device: 1.25 ms for 425 rows in V8.
|
||||
- [ ] <a id="pf6"></a>**PF6 · P2 · S: requests that can be cut, all seen on the wire.**
|
||||
- Every drawer open sends 14 `Email/query` calls for tag counts.
|
||||
- The Contacts tab re-downloads all cards (58 KB) on every open, although they load at
|
||||
startup.
|
||||
- Returning to the Unified Inbox reloads page 1 of every account
|
||||
(`src/screens/UnifiedInboxScreen.tsx:133-137`).
|
||||
- The echo of the app's own writes triggers one or two full refresh cycles (10 requests after
|
||||
one send).
|
||||
- Starts and message opens send an `x:Account/get` that non-admin users are always refused.
|
||||
- The sort-polarity probe runs on every start and is only cached in memory
|
||||
(`src/lib/keyword-sort-polarity.ts:61-81`).
|
||||
- Resume sends 2 `Core/echo`s, then waits about 0.8 s before syncing.
|
||||
- There is a 30 s `Core/echo` on top of the SSE pings (`App.tsx:581-596`).
|
||||
- Backgrounding is fine: no requests during 40 s in the background.
|
||||
- **Verified.**
|
||||
- [ ] <a id="pf7"></a>**PF7 · P2 · S: every list page and every search is 3 sequential requests.**
|
||||
- The chain is `Email/query` → `Email/get` → `Thread/get` (the last one fired after render).
|
||||
First login adds two `Mailbox/get` and a 500 ms gap.
|
||||
- Where: `src/stores/email-store.ts:2049-2060`, `src/screens/EmailListScreen.tsx:339-358`.
|
||||
- Web: `lib/jmap/client.ts` on `perf/fastest-mail` (query, get and `Thread/get` via
|
||||
`#ids /list/*/threadId` in one request). RN's `src/api/unified-inbox.ts:383-397` already
|
||||
chains query and get.
|
||||
- **Verified.**
|
||||
- [ ] <a id="pf8"></a>**PF8 · P2 · M: each keystroke in the composer re-renders the 3,000-line
|
||||
screen twice.**
|
||||
- Where: `src/lib/editor-html.ts:160-168` posts the full `innerHTML` on every input and on every
|
||||
selection change; `src/screens/ComposeScreen.tsx:2276-2277`.
|
||||
- `htmlToPlainText` and a `JSON.stringify` snapshot also run on every render.
|
||||
- **Suspected.**
|
||||
- [ ] <a id="pf9"></a>**PF9 · P2 · M: the APK is 42.8 MB (arm64) with R8 off.**
|
||||
- Where: `android/app/build.gradle:72` and `:126-129`.
|
||||
- Contents: 25.85 MB of unminified dex, 24,621 classes; the ML Kit barcode scanner accounts for
|
||||
5.83 MB. The UnifiedPush work in progress adds 3,009 classes (Tink, protobuf).
|
||||
- **Verified** (APK inspection of the published 0.1.62).
|
||||
- [ ] <a id="pf10"></a>**PF10 · P3 · S: slow paths that need large data to show.**
|
||||
- Contacts search re-sorts with `localeCompare` on every keystroke
|
||||
(`src/stores/contacts-store.ts:923-925`).
|
||||
- Bulk mark-read and star send N requests against a concurrency limit of 4
|
||||
(`src/screens/EmailListScreen.tsx:668-681`); `setKeywordForEmails` exists at
|
||||
`src/stores/email-store.ts:1530`.
|
||||
- Files upload progress is not throttled.
|
||||
- The offline-cache index is rewritten once per message.
|
||||
- **Suspected.**
|
||||
|
||||
## Missing vs webmail
|
||||
|
||||
Status: *missing* (no native equivalent), *partial*, *diverges* (exists but behaves differently or
|
||||
wrongly). Items already listed under Bugs are only cross-referenced here.
|
||||
|
||||
### Auth and accounts
|
||||
|
||||
- [ ] **P2 · S · missing: adding a second OAuth account silently signs in the same user again.**
|
||||
- Where: `src/lib/oauth-native.ts:127-141` (no `prompt=select_account`, no ephemeral browser
|
||||
session).
|
||||
- Web: `5771a213`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · partial: a pasted `…/jmap/session` URL fails discovery with a 404.**
|
||||
- Where: `src/lib/server-discovery.ts:59-63`.
|
||||
- Web: `b26c1746` (#971).
|
||||
- **Suspected.**
|
||||
- Also see B15 (#892 redirect).
|
||||
|
||||
### Mail list, folders and search
|
||||
|
||||
- [ ] **P2 · L · diverges: "All folders" search only covers the open folder's account (#1082).**
|
||||
- Where: `src/stores/email-store.ts:384-389`.
|
||||
- Web: `lib/unified-mailbox.ts` (`searchAcrossAccounts`, `9ef7068b`).
|
||||
- Repro: "zephyr" with All folders → `Email/query` on account `c` only → 3 hits. The 5 matches
|
||||
in the team inbox are missing.
|
||||
- **Verified.**
|
||||
- [ ] **P2 · M · diverges: the tag view and tag badges only cover one account (#1038).**
|
||||
- Where: `src/stores/email-store.ts:209` and `:387`, `src/components/SidebarDrawer.tsx:426-436`.
|
||||
- Web: `stores/email-store.ts` (`fetchTagEmails`, `fedfc89d`).
|
||||
- Repro: 2 own and 2 team messages tagged Red → the Red view shows 2.
|
||||
- **Verified.**
|
||||
- [ ] **P3 · S · missing: folders created on the phone are not subscribed.**
|
||||
- Where: `src/api/email.ts:211-221` sends no `isSubscribed: true`, so IMAP clients hide them.
|
||||
- Web: `2b186910` (#951).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · diverges: archiving to a role-less "Archives" folder is offered but does nothing.**
|
||||
- Where: the list matches by name (`src/screens/EmailListScreen.tsx:393-395`), but the store
|
||||
needs the role or the exact name (`src/stores/email-store.ts:1202-1205`).
|
||||
- Web: `2b32a011` (#578).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: no unread badge on the app icon.**
|
||||
- expo-notifications `setBadgeCountAsync` is already available.
|
||||
- Web: `hooks/use-app-badge.ts` (`e7d37823`).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: list rows show no attachment chips.**
|
||||
- Web: `0e1c34cd`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: no "clear search when switching folders" setting.**
|
||||
- Web: `12f01409`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · diverges: the Unified section shows even when it can't be populated.**
|
||||
- Where: `src/components/SidebarDrawer.tsx:639`.
|
||||
- Web: `f3f0127c` (#843).
|
||||
- **Suspected.**
|
||||
- Not applicable: global search (Pro, desktop-only); category tabs (only exist when a plugin
|
||||
registers them).
|
||||
|
||||
### Viewer
|
||||
|
||||
- [ ] **P2 · M · missing: no per-message HTML/plain-text toggle, and reopened drafts are forced into
|
||||
the global format.**
|
||||
- Where: `src/screens/ComposeScreen.tsx:531-536`. With plain text on, an HTML draft is
|
||||
flattened on its first autosave.
|
||||
- Web: `f6ad8a59` (#1022), `components/email/compose-format.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · missing: sender avatars ignore address-book photos (#54).**
|
||||
- Web: `components/ui/avatar.tsx` (looks up the sender's contact photo by email; the photo wins
|
||||
over favicons and initials).
|
||||
- Repro: Alice Nguyen's contact photo shows in Contacts; her mail shows "AN" initials.
|
||||
- **Verified.**
|
||||
- [ ] **P2 · S · diverges: the viewer's Spam button is a plain move.**
|
||||
- It sends no `$junk`/`$notjunk` flip and doesn't honour trash-and-read.
|
||||
- Where: `src/screens/EmailThreadScreen.tsx:395-407`. The list and swipe paths are right
|
||||
(`src/stores/email-store.ts:1058-1146`).
|
||||
- Web: `150c25ca` (#695).
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · diverges: viewer Archive silently does nothing for messages that are not in the
|
||||
loaded list.**
|
||||
- This covers the unified inbox, notification taps and deep links.
|
||||
- Where: `src/stores/email-store.ts:1196-1197`.
|
||||
- Web: `150c25ca`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · partial: failed actions and sends give no feedback.**
|
||||
- The store actions are called with `void`, and there is no "Email sent" toast.
|
||||
- Web: `ec67ab99`, `b03a0c1d`, `lib/email-action-toast.ts`.
|
||||
- **Verified** (no toast after send); failure path suspected.
|
||||
- [ ] **P3 · S · missing: body-embedded `cid:` parts typed octet-stream appear as nameless chips.**
|
||||
- Where: `src/lib/attachment-display.ts:91-104`.
|
||||
- Web: `1c00812a`.
|
||||
- **Suspected.**
|
||||
- Also see B14 (truncation), U3 (image sizing), U4 (RTL), B5, B3.
|
||||
|
||||
### Composer
|
||||
|
||||
- [ ] **P2 · S · diverges: replies go out from the default identity unless "Reply from received
|
||||
address" is on.**
|
||||
- Forward also rewrites the From address, and replies to your own sent mail resolve from the
|
||||
other party.
|
||||
- Where: `src/screens/ComposeScreen.tsx:836-845`, `src/lib/reply-identity.ts:46-77,170`.
|
||||
- Web: `4fc7b8b5`, `lib/reply-identity.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: a new message in a shared folder doesn't default to that mailbox's
|
||||
identity.**
|
||||
- Where: `findComposeIdentityId` is ported but never called.
|
||||
- Web: `129f545a`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: no exact-address vs same-domain choice for "reply from received".**
|
||||
- Web: `565d5c10` (#1000).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · M · partial: no font-size picker or background colour; 8 text colours instead of 16.**
|
||||
- Where: `src/components/RichTextEditor.tsx:8-26`.
|
||||
- Web: `7e0a86a6`, `3957b358`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · diverges: quick replies carry no signature.**
|
||||
- Where: `src/components/email/QuickReplyBox.tsx:79-90`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · partial: the undo-send snackbar exists only on the list screen.**
|
||||
- A reply sent from the viewer can't be undone.
|
||||
- Where: `src/screens/EmailListScreen.tsx:1563`.
|
||||
- **Suspected.**
|
||||
- Also see B8 (#57), B16.
|
||||
|
||||
### Calendar
|
||||
|
||||
- [ ] **P2 · S · missing: CalDAV tasks with `COMPLETED`, `NEEDS_ACTION` or `canceled` count as
|
||||
pending and toggle the wrong way.**
|
||||
- Web: `5f1d2795` (`normalizeCalendarTask`).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · L · missing: no free-scrolling month, week or day views (#759) and no setting for them.**
|
||||
- Where: `src/screens/CalendarScreen.tsx:89,169-171,360-374`.
|
||||
- Web: `15a5497a`, `ae403b06`, `hooks/use-scroll-window.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · M · missing: the agenda is a fixed 30-day page instead of infinite scroll.**
|
||||
- Where: `src/components/calendar/AgendaView.tsx:46`.
|
||||
- Web: `60e05c00`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · L · diverges: recurring events are always expanded client-side.**
|
||||
- Where: `src/api/calendar.ts:267-304`, `src/lib/recurrence-expansion.ts`.
|
||||
- This matches the webmail's pre-0.16.20 fallback, but there is no server-side expansion and no
|
||||
per-occurrence edit through synthetic ids.
|
||||
- Web: `lib/recurrence-instances.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · partial: participants aren't deduplicated or matched to contact names, and the
|
||||
organizer's status dot is wrong.**
|
||||
- Where: `src/lib/calendar-participants.ts:130-167`.
|
||||
- Web: `8cbc5bc5`, `6f99c017`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · diverges: a task's due time shows the raw Europe/Berlin wall time (17:00), while
|
||||
events are converted to the device zone.**
|
||||
- **Suspected.**
|
||||
- Links in event descriptions are fixed on GitHub `main` (`c2584c2`) but not in this checkout.
|
||||
- Also see B12, B17, B21, B22.
|
||||
|
||||
### Contacts
|
||||
|
||||
- [ ] **P2 · S · partial: the vCard FN (`name.full`) is missing or stale on form, "add sender" and
|
||||
import writes (#430).**
|
||||
- Where: `src/screens/ContactFormScreen.tsx:288-293`, `src/lib/vcard.ts:575-603`.
|
||||
- Web: `a210b22e`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: no sort by last name (#963).**
|
||||
- Where: `src/stores/contacts-store.ts:923-925`.
|
||||
- Web: `fb24ef5d`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · missing: a new contact made while viewing address book X lands in the default
|
||||
book.**
|
||||
- Where: `src/screens/ContactsScreen.tsx:238,396`.
|
||||
- Web: `494adb9b`.
|
||||
- **Suspected.**
|
||||
|
||||
### Files
|
||||
|
||||
- [ ] **P2 · S · diverges: sharing with other users is never offered.**
|
||||
- Where: `src/api/files.ts:30-35` and `src/components/calendar/CalendarShareSheet.tsx:86` gate
|
||||
on `principals:owner`, which Stalwart advertises nowhere. The session only has `principals`
|
||||
and `principals:availability`.
|
||||
- Web: `components/files/files-app.tsx` (gates on `urn:ietf:params:jmap:principals`).
|
||||
- Repro: the per-file menu shows Preview/Share (OS), Download, Move, Duplicate, Rename,
|
||||
Delete, and no "Share with…".
|
||||
- **Verified.**
|
||||
- [ ] **P3 · M · missing: no in-app file preview.**
|
||||
- Tapping a file downloads it and opens the OS "Open with" chooser.
|
||||
- **Verified.**
|
||||
- Also see B13 (500-node cap). WOPI editing (#425) is listed under open questions.
|
||||
|
||||
### Filters and vacation
|
||||
|
||||
- [ ] **P2 · S · missing: "Keep" is still written as `keep;`.**
|
||||
- Saving filters on the phone therefore undoes the webmail fix for allow-list rules.
|
||||
- Where: `src/lib/sieve/generator.ts:131-132`.
|
||||
- Web: `62465e1e` (#1027).
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · M · missing: no filters or vacation for shared/group accounts.**
|
||||
- Where: `src/api/sieve.ts:20-23`, `src/api/vacation.ts:26-27`.
|
||||
- **Suspected.**
|
||||
|
||||
### Settings, themes and i18n
|
||||
|
||||
- [ ] **P2 · M · partial: locale catalogs are stale.**
|
||||
- Last synced 2026-08-29 (`990cd84`): 2,959 keys against the webmail's 3,065. The German
|
||||
update changed 59 values.
|
||||
- `email_viewer.forward_as_attachment` is still English in about 25 locales.
|
||||
- The 567 RN-only keys are English in every locale (`src/i18n/index.ts:98-100`).
|
||||
- Fix: run `scripts/sync-locales.mjs` and translate the RN overlay.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · partial: the Reading pane hard-codes English.**
|
||||
- Labels such as "Message Spacing", "Read Receipts" and "Always send" don't go through `t()`
|
||||
(`src/components/settings/ReadingSettings.tsx:305-327`).
|
||||
- **Verified.**
|
||||
- [ ] **P3 · S · diverges: plural forms probably fall back to English rules.**
|
||||
- Where: `src/i18n/format.ts:18-47`. Hermes on Android has no `Intl.PluralRules`, so Russian
|
||||
shows "2 писем".
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · M · missing: only 4 of the webmail's 7 built-in themes, and no "Flat fields".**
|
||||
- Where: `src/theme/builtin-themes.ts`.
|
||||
- Web: `ab67d9b0`, `lib/theme-flat-fields.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · M · missing: no settings search.**
|
||||
- Web: `lib/settings-search.ts`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · L · diverges: native still uses Lucide icons; the webmail moved to Tabler (`d7548a4b`).**
|
||||
- This is a product decision, not a bug.
|
||||
- **Suspected.**
|
||||
|
||||
### Push
|
||||
|
||||
- [ ] **P1 · S · diverges: tapping a group-mailbox notification opens the message against the wrong
|
||||
account.**
|
||||
- The tap payload carries only the registry account, not the JMAP account.
|
||||
- Where: `src/lib/push-background-task.ts:448-460`, `App.tsx:85-89`.
|
||||
- Web: `b4f12b67` (#839).
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · diverges: `emailPush` is never read back, so the subscription is re-patched on
|
||||
every launch.**
|
||||
- `using` also lacks the emailpush capability.
|
||||
- Where: `src/api/push.ts:11` (`PushSubscription/get` with default properties).
|
||||
- Web: `lib/jmap/client.ts` (explicit properties).
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · diverges: revoking a device or disabling one account doesn't stick.**
|
||||
- Setup re-runs for the active account on every launch (HEAD `App.tsx:447-453`).
|
||||
- **Suspected.**
|
||||
- UnifiedPush (#44, #48) exists only as uncommitted work in this tree. Also see B18.
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] <a id="s1"></a>**S1 · P0 · S · missing: the GHSA-w38p fix (read-receipt header injection) was
|
||||
never ported.**
|
||||
- Where: `src/lib/mdn.ts:77-81` (`encodeHeaderWord` passes pure-ASCII values, CR/LF
|
||||
included, through raw), `:111-113`, `:140-141`.
|
||||
- Web: `lib/mdn.ts` (`stripHeaderControls`, `headerAtom`; `53a6670e`).
|
||||
- Repro:
|
||||
- Subject `=?UTF-8?B?…?=` decoding to `Contract update\r\nX-Injected: yes\r\nReply-To: attacker@evil.example`.
|
||||
- Tap "Send receipt". The MDN stored in Sent and submitted has `X-Injected: yes` and
|
||||
`Reply-To: attacker@evil.example` as real header lines.
|
||||
- A blank line in the payload would give the attacker the body too.
|
||||
- With B7 this becomes zero-click: the malicious message only needs to sit next to one the
|
||||
user opens.
|
||||
- **Verified.**
|
||||
- [ ] <a id="s2"></a>**S2 · P0 · S–M: releases are signed with the public React Native debug key.**
|
||||
- Where: `android/app/build.gradle:103-125`; `.github/workflows/release-android.yml:69-91` falls
|
||||
back to it silently when `ANDROID_KEYSTORE_BASE64` is empty; `android/app/build.gradle:98`
|
||||
(`versionCode 1`).
|
||||
- Evidence: GitHub release 0.1.62 `bulwark-mobile-0.1.62-6c82130.apk` is signed by
|
||||
`CN=Android Debug`, SHA-1 `5e8f1606…f625`. That is the stock template key, and its SHA-256
|
||||
matches the committed `android/app/debug.keystore` (password `android`).
|
||||
- Risk: anyone can sign an "update" that installs over the app and reads its SecureStore
|
||||
credentials.
|
||||
- Fix: a private key, a workflow that fails without it, v3 key rotation for existing
|
||||
installs, and a `versionCode` derived from `VERSION`.
|
||||
- **Verified.**
|
||||
- [ ] **P2 · S · diverges: "Sync trusted senders to address book" trusts every contact in every
|
||||
book.**
|
||||
- Remote images and tracking pixels then load for any contact, including a spoofed From.
|
||||
- The toggle switches itself on the first time the Content & Senders pane is opened on a
|
||||
contacts-capable account (`src/components/settings/ContentSendersSettings.tsx:54-57`).
|
||||
- Where: `src/components/EmailBodyView.tsx:533-544,562-566`.
|
||||
- Web: `components/email/email-viewer.tsx` (only the Trusted Senders book counts).
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · missing: replying or forwarding loads the remote images the viewer blocked.**
|
||||
- The editor page has no CSP (`src/components/RichTextEditor.tsx:223-253`,
|
||||
`src/lib/editor-html.ts:29-35`).
|
||||
- **Suspected.**
|
||||
- [ ] **P2 · S · diverges: the webmail-to-app sign-in hand-off puts the password in the
|
||||
`bulwarkmobile://…#password=` fragment.**
|
||||
- Where: `src/lib/oauth.ts:139-146`. The webmail side is `app/(main)/[locale]/login/page.tsx`.
|
||||
- **Suspected.**
|
||||
- [ ] **P3 · S · diverges: list and notification favicons are fetched from icons.duckduckgo.com on
|
||||
the device.**
|
||||
- This leaks sender domains and the device IP to a third party.
|
||||
- Where: `src/lib/avatar-utils.ts:138-139`.
|
||||
- Web: server-side `/api/favicon`.
|
||||
- **Suspected.**
|
||||
- Body WebView hardening is at parity: `about:blank` origin, no file access, DOM storage or
|
||||
cookies, a `default-src 'none'` CSP, and the link-scheme gate.
|
||||
|
||||
## Stale parity checklist
|
||||
|
||||
Of the 418 items, 362 are ticked and 56 open; the counts match `PARITY_CHECKLIST.md`. The
|
||||
spot-check covered 35 ticks plus the ones re-tested on the device.
|
||||
|
||||
### Ticked, but broken
|
||||
|
||||
- [ ] `docs/parity/03-email-viewer.md:107`, read receipts: sent unsanitized, and sent for unseen
|
||||
neighbour panes (S1, B7). **Verified.**
|
||||
- [ ] `docs/parity/02-mail-list-folders.md:112` and `docs/parity/03-email-viewer.md:15`, pager and
|
||||
wrong-message fixes: the body is right, but actions still hit the wrong account and message
|
||||
(B3, B5). **Verified.**
|
||||
- [ ] `docs/parity/05-calendar.md:268`, task edit/toggle: completing fails (B12). **Verified.**
|
||||
- [ ] `docs/parity/08-settings-push-i18n-ui.md:297`, deep links, mailto and share targets: SENDTO,
|
||||
shared files and `+` addresses all fail (B9–B11). **Verified.**
|
||||
- [ ] `docs/parity/07-filters-vacation-files.md:177` ("Verified at parity: `FileNode/get
|
||||
ids:null`"): capped at 500 (B13). Its claim that `/query` drops folders has been outdated since
|
||||
Stalwart 0.16.6. **Verified.**
|
||||
- [ ] `docs/parity/02-mail-list-folders.md:244`, search all folders (#788): scope is one account
|
||||
(#1082 above). **Verified.**
|
||||
- [ ] `docs/parity/02-mail-list-folders.md:187`, `$junk`/`$notjunk` flip: the viewer's buttons
|
||||
still do a plain move. **Suspected.**
|
||||
- [ ] `docs/parity/01-auth-accounts.md:57` and `docs/parity/09-jmap-core-sync-security.md:99`,
|
||||
redirect with a dropped Authorization header (B15). **Suspected.**
|
||||
- [ ] `docs/parity/04-composer-send.md:12`, draft autosave: loses text offline, and iOS swipe-down
|
||||
bypasses the dialog (B16). **Suspected.**
|
||||
- [ ] `docs/parity/04-composer-send.md:224`, reschedule: drops Cc and Bcc (B19). **Suspected.**
|
||||
- [ ] `docs/parity/05-calendar.md:261`, reminders: cancelled when the view moves (B17).
|
||||
**Suspected.**
|
||||
- [ ] `docs/parity/06-contacts.md:259`, trusted-senders book: trusts every contact (Security).
|
||||
**Suspected.**
|
||||
- [ ] `docs/parity/08-settings-push-i18n-ui.md:139` and `:154`, push types and the `emailPush`
|
||||
filter: never read back, and breaks for ACL-shared accounts (B18). **Suspected.**
|
||||
- [ ] `docs/parity/02-mail-list-folders.md:217`, date-locale: the tick claims a `dateLocale` setting
|
||||
that doesn't exist. **Suspected.**
|
||||
|
||||
### Unticked, but done or obsolete
|
||||
|
||||
- **Done (tick them):**
|
||||
|
||||
| Item | Where it landed |
|
||||
|---|---|
|
||||
| `01:109` remove a non-active account | `6ae21d5`, `490355c` |
|
||||
| `01:114` switcher error state | |
|
||||
| `01:129` account cap | now 10, `src/lib/account-utils.ts:4` |
|
||||
| `01:139` unified shared-account filter | |
|
||||
| `02:163` permanent-delete confirm | `src/screens/EmailThreadScreen.tsx:361-386` |
|
||||
| `02:168` "never" mark-as-read | |
|
||||
| `04:256` mailto/share intents | Android; broken per B9–B11 |
|
||||
| `06:200` add sender to contacts | |
|
||||
| `06:281` auto-trust reply recipients | |
|
||||
| `07:164` quota | shown in Account settings |
|
||||
| `09:131` contact state changes for shared accounts | |
|
||||
- **Obsolete (drop them):**
|
||||
- `05:91` natural-language event input: the webmail has no parser.
|
||||
- `06:294` print contact: not applicable on mobile.
|
||||
- `07:45` Sieve editor gutter/Tab: not applicable on mobile.
|
||||
- **Text that is now wrong:**
|
||||
- `05:254`: push types are `['EmailDelivery']`.
|
||||
- `05:222`, `06:215`, `07:152`: "no linking config" (untrue since `5802cae`).
|
||||
- `08:15`, `08:235`: `useTypography` has 1 caller, not 3.
|
||||
- `08:223`: "no RTL" (untrue since `990cd84`).
|
||||
- `08:122`: cites `lib/error-reporting.ts`, which the webmail deleted.
|
||||
- `07` "Verified at parity" Sieve generator: the webmail changed "Keep" (`62465e1e`).
|
||||
|
||||
## Open issues on bulwarkmail/native
|
||||
|
||||
| Issue | Status after this audit |
|
||||
|---|---|
|
||||
| #57 scheduled sending unsupported | Root cause found and reproduced (B8). |
|
||||
| #54 contact photos not used as avatars | Reproduced (Viewer, "sender avatars"). |
|
||||
| #50 crash opening any mail | Not reproduced on 0.1.62; likely fixed by `f228257`. Ask the reporter, then close. |
|
||||
| #47 account security not accessible | Fixed: Security → Account Security works on 0.16.23. Close. |
|
||||
| #46 email preview shows CSS | Not reproduced: HTML-only mail with `<style>` shows clean previews, and the viewer routes by type (`06742ef`). Ask for a sample, then close. |
|
||||
| #44, #48 push without FCM / UnifiedPush | Uncommitted work in this tree; not in any release. |
|
||||
| #3 TLS client auth | Partial: Android has a certificate picker (Settings → Security); iOS doesn't. |
|
||||
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #34 sync adapters, #1 settings sync | Feature requests; see open questions. |
|
||||
|
||||
No open webmail issue is about the native app. The six mobile-titled ones (#813, #838, #915,
|
||||
#969, #999, #1020) concern the web and PWA.
|
||||
|
||||
## Open questions (product decisions)
|
||||
|
||||
- **Tasks** are off by default (`enableCalendarTasks: false`). Should they be on, now that the
|
||||
webmail ships them?
|
||||
- **Design language:** should native follow the webmail's "Flat fields" theme and Tabler icons,
|
||||
or keep its own look?
|
||||
- **Office documents:** WOPI editing (#425) needs the webmail as the WOPI host. Should native open
|
||||
office files in Collabora through the webmail, or keep handing them to the OS?
|
||||
- **Barcode scanner:** the bundled ML Kit scanner costs 5.8 MB and starts with every process,
|
||||
including headless pushes. Google's code scanner needs Play services, which conflicts with the
|
||||
Google-free UnifiedPush audience.
|
||||
- **Package name:** `com.anonymous.bulwarkmobile` (Android) vs `org.bulwarkmail.mobile` (iOS).
|
||||
This must be decided before any store listing, because changing it later means a new app.
|
||||
- **Read receipts and the pager:** with neighbour pages pre-rendered, should "Always send" wait
|
||||
for the page to be visible for some time, or apply only to the message that was actually
|
||||
opened?
|
||||
- **Settings sync (#1):** needs a server-side store, which neither client has today.
|
||||
- **Scope of the feature requests:** encryption (#58), widgets (#52), IMAP/SMTP (#51) and native
|
||||
sync adapters (#34).
|
||||
|
||||
## Reproducing
|
||||
|
||||
- **Test server:** the container `stw-native-audit` (Stalwart 0.16.23, `127.0.0.1:18095`) is
|
||||
stopped with the seeded data kept.
|
||||
- Start it with `docker start stw-native-audit`.
|
||||
- Test accounts `usera@example.org` and `userb@example.org` use the same passwords as on
|
||||
`stw-test19`; the recovery admin password is in the maintainer's notes, not here.
|
||||
- To seed, raise `x:Jmap/set` `maxUploadCount` and `uploadQuota` first: the default of 1000
|
||||
uploads or 50 MB per hour runs out.
|
||||
- Email/import does not resolve a `#creationId` blobId from `Blob/upload` in the same request.
|
||||
- **Emulator:** the server is `http://10.0.2.2:<port>`, which needs `android:usesCleartextTraffic`.
|
||||
- Build from PowerShell in `C:\rn\android` with `NODE_PRESERVE_SYMLINKS=1`, JDK 17 and
|
||||
`-PreactNativeArchitectures=x86_64`.
|
||||
- For request traces, put a logging reverse proxy in front of the server. Pick a free local
|
||||
port; 18096 and 18097 were already taken on this machine.
|
||||
- **Incidental, webmail (suspected):**
|
||||
- `lib/jmap/client.ts:3621` (and 3895, 4075, 4229, 8403, 8490) tests
|
||||
`methodName.endsWith('/error')`. That never matches JMAP's `error` response, so a method-level
|
||||
`EmailSubmission/set` error comes back as a successful send.
|
||||
- `lib/web-push.ts:86-94` has the same shared-account `emailPush` problem as B18.
|
||||
+10
-2
@@ -529,16 +529,24 @@
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"phase_account": "Account",
|
||||
"phase_distributor": "UnifiedPush distributor",
|
||||
"phase_jmap": "Mail server subscription",
|
||||
"phase_permission": "Notification permission",
|
||||
"phase_platform": "Platform",
|
||||
"phase_relay": "Relay registration",
|
||||
"phase_token": "Device token (Firebase)",
|
||||
"phase_token": "Device token",
|
||||
"phase_verify": "Verification",
|
||||
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
|
||||
"relay_invalid": "Enter a valid https:// URL",
|
||||
"setup_failed": "Setup failed",
|
||||
"status_android_only": "Background notifications are available on Android only."
|
||||
"status_android_only": "Background notifications are available on Android only.",
|
||||
"transport_desc": "How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
"transport_label": "Delivery method",
|
||||
"transport_unifiedpush": "UnifiedPush",
|
||||
"up_distributor_desc": "Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.",
|
||||
"up_distributor_label": "Distributor",
|
||||
"up_no_distributor": "No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again."
|
||||
}
|
||||
},
|
||||
"offline": {
|
||||
|
||||
@@ -29,9 +29,18 @@ import {
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
getStoredPushTransport,
|
||||
setStoredPushTransport,
|
||||
type PushDevice,
|
||||
type PushSetupPhase,
|
||||
type PushTransport,
|
||||
} from '../../lib/push-notifications';
|
||||
import {
|
||||
getSavedUnifiedPushDistributor,
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
saveUnifiedPushDistributor,
|
||||
} from '../../lib/unified-push';
|
||||
|
||||
type PushStatus =
|
||||
| { kind: 'idle' }
|
||||
@@ -48,7 +57,8 @@ type DevicesState =
|
||||
const PHASE_KEYS: Record<PushSetupPhase, { key: string; fallback: string }> = {
|
||||
platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' },
|
||||
permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' },
|
||||
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token (Firebase)' },
|
||||
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token' },
|
||||
distributor: { key: 'settings.notifications.push.phase_distributor', fallback: 'UnifiedPush distributor' },
|
||||
account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' },
|
||||
relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' },
|
||||
jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' },
|
||||
@@ -71,7 +81,11 @@ export function NotificationSettings() {
|
||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
|
||||
const supported = isPushSupported();
|
||||
const upSupported = isUnifiedPushSupported();
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const [transport, setTransport] = useState<PushTransport>('fcm');
|
||||
const [distributors, setDistributors] = useState<string[]>([]);
|
||||
const [savedDistributor, setSavedDistributor] = useState<string | null>(null);
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
@@ -92,8 +106,16 @@ export function NotificationSettings() {
|
||||
if (stored) setRelayUrl(stored);
|
||||
setPushEnabled(enabled);
|
||||
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
|
||||
const storedTransport = await getStoredPushTransport();
|
||||
const upDistributors = upSupported ? await getUnifiedPushDistributors() : [];
|
||||
const upSaved = upSupported ? await getSavedUnifiedPushDistributor() : null;
|
||||
if (cancelled) return;
|
||||
setTransport(storedTransport ?? 'fcm');
|
||||
setDistributors(upDistributors);
|
||||
setSavedDistributor(upSaved);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeAccountId]);
|
||||
|
||||
const trimmed = relayUrl.trim().replace(/\/+$/, '');
|
||||
@@ -155,6 +177,27 @@ export function NotificationSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Switching the transport while push is on re-registers immediately so the
|
||||
// relay record flips to the new delivery path; while off it just records
|
||||
// the preference for the next enable.
|
||||
const handleSelectTransport = async (next: PushTransport) => {
|
||||
if (next === transport || busy) return;
|
||||
setTransport(next);
|
||||
await setStoredPushTransport(next);
|
||||
if (pushEnabled) void handleEnable(true);
|
||||
};
|
||||
|
||||
const handleSelectDistributor = async (distributor: string) => {
|
||||
if (distributor === savedDistributor || busy) return;
|
||||
setSavedDistributor(distributor);
|
||||
try {
|
||||
await saveUnifiedPushDistributor(distributor);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (pushEnabled && transport === 'unifiedpush') void handleEnable(true);
|
||||
};
|
||||
|
||||
const performDisable = async () => {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({
|
||||
@@ -239,6 +282,68 @@ export function NotificationSettings() {
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{upSupported && (
|
||||
<>
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.transport_label', 'Delivery method')}
|
||||
description={t(
|
||||
'settings.notifications.push.transport_desc',
|
||||
'How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
variant={transport === 'fcm' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectTransport('fcm')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.notifications.push.transport_fcm', 'Google (FCM)')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={transport === 'unifiedpush' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectTransport('unifiedpush')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.notifications.push.transport_unifiedpush', 'UnifiedPush')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
{transport === 'unifiedpush' && distributors.length === 0 && (
|
||||
<Text style={styles.errorText}>
|
||||
{t(
|
||||
'settings.notifications.push.up_no_distributor',
|
||||
'No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{transport === 'unifiedpush' && distributors.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.up_distributor_label', 'Distributor')}
|
||||
description={t(
|
||||
'settings.notifications.push.up_distributor_desc',
|
||||
'Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.distributorList}>
|
||||
{distributors.map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant={d === savedDistributor ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onPress={() => void handleSelectDistributor(d)}
|
||||
disabled={busy}
|
||||
>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</SettingItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.relay_label', 'Push relay')}
|
||||
description={t(
|
||||
@@ -450,6 +555,7 @@ function makeStyles(c: ThemePalette) {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
urlInputInvalid: { borderColor: c.error },
|
||||
distributorList: { flexDirection: 'column', alignItems: 'flex-end', gap: spacing.sm },
|
||||
errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
|
||||
|
||||
@@ -17,6 +17,19 @@ vi.mock('react-native', () => {
|
||||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||||
deleteToken: vi.fn(async () => undefined),
|
||||
},
|
||||
BulwarkUnifiedPush: {
|
||||
getDistributors: vi.fn(async () => ['io.heckel.ntfy']),
|
||||
getSavedDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||||
getAckDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||||
saveDistributor: vi.fn(async () => undefined),
|
||||
register: vi.fn(async () => undefined),
|
||||
unregister: vi.fn(async () => undefined),
|
||||
getEndpoint: vi.fn(async () => ({
|
||||
url: 'https://ntfy.sh/upAbCdEf?up=1',
|
||||
p256dh: 'B'.repeat(87),
|
||||
auth: 'a'.repeat(22),
|
||||
})),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
@@ -273,6 +286,103 @@ describe('setupPushNotifications subscription shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications over UnifiedPush', () => {
|
||||
type UpNative = {
|
||||
getDistributors: ReturnType<typeof vi.fn>;
|
||||
getSavedDistributor: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
getEndpoint: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const upNative = () => (NativeModules as { BulwarkUnifiedPush: UpNative }).BulwarkUnifiedPush;
|
||||
|
||||
// Like installFetch, plus the two UnifiedPush relay endpoints; captures the
|
||||
// registration body so tests can assert on it.
|
||||
function installUpFetch(): { body: () => Record<string, unknown> | null } {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/vapid-public-key')) {
|
||||
return { ok: true, status: 200, json: async () => ({ publicKey: 'VAPID-PUB' }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/register/unifiedpush')) {
|
||||
captured = JSON.parse(String(init?.body ?? 'null')) as Record<string, unknown>;
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
return { body: () => captured };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
await AsyncStorage.setItem('push:transport:v1', 'unifiedpush');
|
||||
listMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('registers the distributor endpoint with the relay instead of an FCM token', async () => {
|
||||
const relayReg = installUpFetch();
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
|
||||
expect(native.getToken).not.toHaveBeenCalled();
|
||||
// The relay's VAPID key is handed to the distributor at registration.
|
||||
expect(upNative().register).toHaveBeenCalledWith('VAPID-PUB');
|
||||
expect(relayReg.body()).toEqual({
|
||||
subscriptionId: OUR_DCID,
|
||||
endpoint: 'https://ntfy.sh/upAbCdEf?up=1',
|
||||
keys: { p256dh: 'B'.repeat(87), auth: 'a'.repeat(22) },
|
||||
accountLabel: undefined,
|
||||
});
|
||||
// The JMAP subscription flow is transport-independent.
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
|
||||
upNative().getEndpoint.mockResolvedValue({
|
||||
url: 'https://legacy.example/up123456',
|
||||
p256dh: null,
|
||||
auth: null,
|
||||
});
|
||||
const relayReg = installUpFetch();
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(relayReg.body()).toMatchObject({
|
||||
endpoint: 'https://legacy.example/up123456',
|
||||
keys: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with the distributor phase when no distributor is installed', async () => {
|
||||
upNative().getDistributors.mockResolvedValue([]);
|
||||
installUpFetch();
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('distributor');
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires an explicit choice when several distributors are installed', async () => {
|
||||
upNative().getDistributors.mockResolvedValue(['io.heckel.ntfy', 'org.unifiedpush.distributor.sunup']);
|
||||
upNative().getSavedDistributor.mockResolvedValue(null);
|
||||
installUpFetch();
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err.phase).toBe('distributor');
|
||||
expect(err.message).toContain('choose one');
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardownPushNotificationsForAccount', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
+139
-15
@@ -11,11 +11,22 @@ import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import type { EmailPushConfig, Mailbox } from '../api/types';
|
||||
import { generateAccountId } from './account-utils';
|
||||
import {
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
registerUnifiedPush,
|
||||
unregisterUnifiedPush,
|
||||
UnifiedPushRegisterError,
|
||||
type UnifiedPushEndpoint,
|
||||
} from './unified-push';
|
||||
|
||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
||||
// after app restarts. Each account gets its own deviceClientId so the hosted
|
||||
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||
// Which transport carries pushes to this device: FCM (default) or a
|
||||
// UnifiedPush distributor. Device-wide, like the relay URL.
|
||||
const PUSH_TRANSPORT_KEY = 'push:transport:v1';
|
||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
|
||||
// Local account id (username@host) → JMAP primary account id. The relay tags
|
||||
// every forwarded push with the JMAP account id it came from, which is the
|
||||
@@ -276,9 +287,28 @@ function getNative(): BulwarkFcmNative | null {
|
||||
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
|
||||
}
|
||||
|
||||
/** True on platforms that have a push transport wired up (Android/FCM only). */
|
||||
/** True on platforms that have a push transport wired up (Android only). */
|
||||
export function isPushSupported(): boolean {
|
||||
return getNative() !== null;
|
||||
return getNative() !== null || isUnifiedPushSupported();
|
||||
}
|
||||
|
||||
export type PushTransport = 'fcm' | 'unifiedpush';
|
||||
|
||||
export async function getStoredPushTransport(): Promise<PushTransport | null> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
|
||||
return raw === 'fcm' || raw === 'unifiedpush' ? raw : null;
|
||||
}
|
||||
|
||||
export async function setStoredPushTransport(transport: PushTransport | null): Promise<void> {
|
||||
if (!transport) {
|
||||
await AsyncStorage.removeItem(PUSH_TRANSPORT_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(PUSH_TRANSPORT_KEY, transport);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEffectivePushTransport(): Promise<PushTransport> {
|
||||
return (await getStoredPushTransport()) ?? 'fcm';
|
||||
}
|
||||
|
||||
export interface PushSetupParams {
|
||||
@@ -303,6 +333,7 @@ export type PushSetupPhase =
|
||||
| 'platform'
|
||||
| 'permission'
|
||||
| 'token'
|
||||
| 'distributor'
|
||||
| 'account'
|
||||
| 'relay'
|
||||
| 'jmap'
|
||||
@@ -390,16 +421,22 @@ export async function getFcmToken(): Promise<string | null> {
|
||||
// Firebase rejects getToken() on devices without Google Play services (or
|
||||
// with a broken Firebase configuration), and briefly right after a
|
||||
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
|
||||
// settings pane can explain.
|
||||
// settings pane can explain - and point de-Googled devices at UnifiedPush
|
||||
// when a distributor is around to take over.
|
||||
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await native.getToken();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
const distributors = await getUnifiedPushDistributors().catch(() => []);
|
||||
const hint =
|
||||
distributors.length > 0
|
||||
? ' This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.'
|
||||
: '';
|
||||
throw new PushSetupError(
|
||||
'token',
|
||||
`Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`,
|
||||
`Firebase could not issue a device token (${detail}). Push over FCM needs Google Play services on this device.${hint}`,
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
@@ -408,6 +445,39 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
|
||||
* distributor at registration so distributors that pin the application server
|
||||
* accept the relay's pushes; registration proceeds without it (503 on relays
|
||||
* without configured VAPID keys - UnifiedPush delivery still works there).
|
||||
*/
|
||||
async function fetchRelayVapidPublicKey(relayBaseUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { publicKey?: unknown };
|
||||
return typeof body.publicKey === 'string' && body.publicKey ? body.publicKey : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire a UnifiedPush endpoint, mapping failures onto setup phases. */
|
||||
async function getUnifiedPushEndpointOrThrow(
|
||||
relayBaseUrl: string,
|
||||
): Promise<UnifiedPushEndpoint> {
|
||||
const vapid = await fetchRelayVapidPublicKey(relayBaseUrl);
|
||||
try {
|
||||
return await registerUnifiedPush({ vapid });
|
||||
} catch (err) {
|
||||
if (err instanceof UnifiedPushRegisterError) {
|
||||
const phase = err.reason === 'failed' ? 'token' : 'distributor';
|
||||
throw new PushSetupError(phase, err.message);
|
||||
}
|
||||
throw new PushSetupError('token', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function buildRelayUrl(base: string, suffix: string): string {
|
||||
return base.replace(/\/+$/, '') + suffix;
|
||||
}
|
||||
@@ -454,6 +524,37 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerWithRelayUnifiedPush(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
endpoint: UnifiedPushEndpoint;
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
const { endpoint } = params;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/unifiedpush'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
endpoint: endpoint.url,
|
||||
keys:
|
||||
endpoint.p256dh && endpoint.auth
|
||||
? { p256dh: endpoint.p256dh, auth: endpoint.auth }
|
||||
: null,
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The relay's view of a subscription - see relayStatusFor. */
|
||||
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
|
||||
|
||||
@@ -570,10 +671,14 @@ function logPhase(phase: string, detail?: string): void {
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const transport = await getEffectivePushTransport();
|
||||
const native = getNative();
|
||||
if (!native) {
|
||||
if (transport === 'fcm' && !native) {
|
||||
throw new PushSetupError('platform', 'Push notifications are only available on Android.');
|
||||
}
|
||||
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
|
||||
throw new PushSetupError('platform', 'UnifiedPush is only available on Android.');
|
||||
}
|
||||
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
|
||||
@@ -587,8 +692,14 @@ async function setupPushNotificationsInner(
|
||||
throw new PushSetupError('permission', 'Notification permission was not granted.');
|
||||
}
|
||||
|
||||
logPhase('token');
|
||||
const fcmToken = await getFcmTokenOrThrow(native);
|
||||
logPhase('token', transport);
|
||||
let fcmToken: string | null = null;
|
||||
let upEndpoint: UnifiedPushEndpoint | null = null;
|
||||
if (transport === 'unifiedpush') {
|
||||
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
|
||||
} else {
|
||||
fcmToken = await getFcmTokenOrThrow(native!);
|
||||
}
|
||||
|
||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||
// need its username/serverUrl up-front so we can key per-account state.
|
||||
@@ -606,15 +717,25 @@ async function setupPushNotificationsInner(
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
// accounts on the same device end up as separate registrations sharing
|
||||
// one fcmToken - the relay forwards each push individually and tags it
|
||||
// with the JMAP account id so the headless task can route it.
|
||||
// one FCM token / UnifiedPush endpoint - the relay forwards each push
|
||||
// individually and tags it with the JMAP account id so the headless task
|
||||
// can route it.
|
||||
logPhase('relay', relayBaseUrl);
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
fcmToken,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
if (upEndpoint) {
|
||||
await registerWithRelayUnifiedPush({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
endpoint: upEndpoint,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
} else {
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
fcmToken: fcmToken!,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
}
|
||||
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
@@ -853,6 +974,9 @@ export async function teardownPushNotifications(): Promise<void> {
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
}
|
||||
// Also release any UnifiedPush registration so the distributor stops
|
||||
// holding a channel for us. No-op when never registered.
|
||||
await unregisterUnifiedPush().catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface PushDevice {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
|
||||
// Thin JS surface over the BulwarkUnifiedPush native module. UnifiedPush
|
||||
// (https://unifiedpush.org) delivers pushes through a user-installed
|
||||
// distributor app (ntfy, NextPush, ...) instead of Google Play services -
|
||||
// the transport for de-Googled devices. Incoming messages themselves are
|
||||
// dispatched natively through the same headless task / `fcm:message` event
|
||||
// as FCM, so only registration management lives here.
|
||||
|
||||
export interface UnifiedPushEndpoint {
|
||||
url: string;
|
||||
// RFC 8291 Web Push keys from the connector; null with legacy distributors,
|
||||
// in which case the relay falls back to plain (unencrypted ping) delivery.
|
||||
p256dh: string | null;
|
||||
auth: string | null;
|
||||
}
|
||||
|
||||
type BulwarkUnifiedPushNative = {
|
||||
getDistributors(): Promise<string[]>;
|
||||
getSavedDistributor(): Promise<string | null>;
|
||||
getAckDistributor(): Promise<string | null>;
|
||||
saveDistributor(distributor: string): Promise<void>;
|
||||
register(vapid: string | null): Promise<void>;
|
||||
unregister(): Promise<void>;
|
||||
getEndpoint(): Promise<UnifiedPushEndpoint | null>;
|
||||
};
|
||||
|
||||
function getNative(): BulwarkUnifiedPushNative | null {
|
||||
if (Platform.OS !== 'android') return null;
|
||||
return (
|
||||
((NativeModules as Record<string, unknown>).BulwarkUnifiedPush as
|
||||
| BulwarkUnifiedPushNative
|
||||
| undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the app build carries the UnifiedPush connector (Android only). */
|
||||
export function isUnifiedPushSupported(): boolean {
|
||||
return getNative() !== null;
|
||||
}
|
||||
|
||||
/** Package names of the distributor apps installed on this device. */
|
||||
export async function getUnifiedPushDistributors(): Promise<string[]> {
|
||||
const native = getNative();
|
||||
if (!native) return [];
|
||||
try {
|
||||
return await native.getDistributors();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedUnifiedPushDistributor(): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
try {
|
||||
return await native.getSavedDistributor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUnifiedPushDistributor(distributor: string): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) throw new Error('UnifiedPush is not available on this platform.');
|
||||
await native.saveDistributor(distributor);
|
||||
}
|
||||
|
||||
export async function getUnifiedPushEndpoint(): Promise<UnifiedPushEndpoint | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
try {
|
||||
return await native.getEndpoint();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterUnifiedPush(): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) return;
|
||||
await native.unregister().catch(() => undefined);
|
||||
}
|
||||
|
||||
function emitter(): NativeEventEmitter {
|
||||
return new NativeEventEmitter(NativeModules.BulwarkUnifiedPush);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires whenever the distributor hands out a (new) endpoint - including
|
||||
* rotations while the app runs. Callers re-register the endpoint with the
|
||||
* relay, mirroring the FCM token-refresh listener.
|
||||
*/
|
||||
export function addUnifiedPushEndpointListener(
|
||||
listener: (endpoint: UnifiedPushEndpoint) => void,
|
||||
): () => void {
|
||||
if (!isUnifiedPushSupported()) return () => undefined;
|
||||
const sub = emitter().addListener('up:newEndpoint', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export function addUnifiedPushUnregisteredListener(listener: () => void): () => void {
|
||||
if (!isUnifiedPushSupported()) return () => undefined;
|
||||
const sub = emitter().addListener('up:unregistered', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export class UnifiedPushRegisterError extends Error {
|
||||
// 'no-distributor' - nothing installed; 'choose-distributor' - several
|
||||
// installed and none selected yet; 'failed' - the distributor rejected or
|
||||
// never answered the registration.
|
||||
readonly reason: 'no-distributor' | 'choose-distributor' | 'failed';
|
||||
|
||||
constructor(reason: UnifiedPushRegisterError['reason'], message: string) {
|
||||
super(message);
|
||||
this.name = 'UnifiedPushRegisterError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with the saved distributor (auto-selecting it when exactly one is
|
||||
* installed) and resolve with the endpoint. The endpoint arrives as an async
|
||||
* event from the distributor; a previously stored endpoint short-circuits the
|
||||
* wait - if the distributor then rotates it, the up:newEndpoint listener
|
||||
* re-runs setup with the fresh one.
|
||||
*/
|
||||
export async function registerUnifiedPush(params: {
|
||||
vapid?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<UnifiedPushEndpoint> {
|
||||
const native = getNative();
|
||||
if (!native) {
|
||||
throw new UnifiedPushRegisterError('failed', 'UnifiedPush is not available on this platform.');
|
||||
}
|
||||
|
||||
const distributors = await getUnifiedPushDistributors();
|
||||
if (distributors.length === 0) {
|
||||
throw new UnifiedPushRegisterError(
|
||||
'no-distributor',
|
||||
'No UnifiedPush distributor app is installed. Install one (for example ntfy) and try again.',
|
||||
);
|
||||
}
|
||||
let saved = await getSavedUnifiedPushDistributor();
|
||||
if (!saved || !distributors.includes(saved)) {
|
||||
if (distributors.length > 1) {
|
||||
throw new UnifiedPushRegisterError(
|
||||
'choose-distributor',
|
||||
'Several UnifiedPush distributors are installed - choose one in the notification settings.',
|
||||
);
|
||||
}
|
||||
saved = distributors[0];
|
||||
await native.saveDistributor(saved);
|
||||
}
|
||||
|
||||
const timeoutMs = params.timeoutMs ?? 30_000;
|
||||
return new Promise<UnifiedPushEndpoint>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanups: Array<() => void> = [];
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const c of cleanups) c();
|
||||
fn();
|
||||
};
|
||||
|
||||
const epSub = emitter().addListener('up:newEndpoint', (endpoint: UnifiedPushEndpoint) => {
|
||||
finish(() => resolve(endpoint));
|
||||
});
|
||||
cleanups.push(() => epSub.remove());
|
||||
const failSub = emitter().addListener(
|
||||
'up:registrationFailed',
|
||||
(payload: { reason?: string }) => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
`The distributor rejected the registration (${payload?.reason ?? 'unknown'}).`,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
cleanups.push(() => failSub.remove());
|
||||
const timer = setTimeout(() => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
`The distributor did not provide an endpoint within ${Math.round(timeoutMs / 1000)} s.`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
cleanups.push(() => clearTimeout(timer));
|
||||
|
||||
void native
|
||||
.register(params.vapid ?? null)
|
||||
.then(async () => {
|
||||
// A re-registration usually keeps the existing endpoint, and the
|
||||
// distributor may not re-announce it - use the stored one if present.
|
||||
const stored = await native.getEndpoint().catch(() => null);
|
||||
if (stored) finish(() => resolve(stored));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new UnifiedPushRegisterError(
|
||||
'failed',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user