Compare commits

..
7 Commits
21 changed files with 1063 additions and 408 deletions
+61 -39
View File
@@ -59,9 +59,6 @@ jobs:
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials
id: creds
env:
@@ -69,14 +66,69 @@ jobs:
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
if [ -z "$NSE_PROFILE" ]; then
# The notification service extension (plugins/ios-push) is its
# own App ID and needs its own App Store profile.
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
exit 1
fi
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Install the provisioning profiles
if: steps.creds.outputs.signed == 'true'
id: profiles
env:
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
# One App Store profile per target: the app and its notification
# service extension (plugins/ios-push).
for side in app nse; do
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
echo "$DATA" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
# ID from the profile keeps ExportOptions honest if app.config.js
# changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
done
- name: Generate the iOS project
env:
# plugins/ios-push names each target's profile: the command line
# can't, it would give every target the same one.
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
run: npx expo prebuild --platform ios --clean
- name: Resolve workspace and scheme
id: xcode
run: |
@@ -141,38 +193,6 @@ jobs:
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Install the provisioning profile
if: steps.creds.outputs.signed == 'true'
id: profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
# from the profile keeps ExportOptions honest if app.config.js changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
@@ -192,8 +212,7 @@ jobs:
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
CODE_SIGN_IDENTITY="Apple Distribution"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
@@ -215,8 +234,10 @@ jobs:
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
@@ -230,6 +251,7 @@ jobs:
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
+16 -10
View File
@@ -43,14 +43,20 @@ import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
// Screens that aren't on the way to the inbox load on first visit
// (getComponent below), so their modules stay out of the startup work.
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
@@ -317,7 +323,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
</Tab.Screen>
<Tab.Screen
name="Calendar"
getComponent={() => require('./src/screens/CalendarScreen').default}
component={CalendarScreen}
options={{
title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
@@ -333,7 +339,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
getComponent={() => require('./src/screens/ContactsTab').default}
component={ContactsTab}
options={{
title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
@@ -349,7 +355,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Files"
getComponent={() => require('./src/screens/FilesScreen').default}
component={FilesScreen}
options={{
title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
@@ -867,10 +873,10 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen
name="Compose"
getComponent={() => require('./src/screens/ComposeScreen').default}
component={ComposeScreen}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
@@ -880,15 +886,15 @@ export default function App() {
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
name="ContactForm"
getComponent={() => require('./src/screens/ContactFormScreen').default}
component={ContactFormScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen
name="AddAccount"
options={{
+1 -1
View File
@@ -1 +1 @@
0.1.83
0.1.84
+12
View File
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
// fall back to 1 (never uploaded).
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
// Apple team: prefixes the keychain groups the app shares with its
// notification service extension (plugins/ios-push). Not a secret.
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
// Which APNs host the device tokens belong to: TestFlight and App Store builds
// use production, simulators and development builds the sandbox.
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
module.exports = {
expo: {
name: 'Bulwark Mobile',
@@ -96,12 +103,17 @@ module.exports = {
microphonePermission: false,
},
],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
],
extra: {
commit: COMMIT,
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
// to where its own source is (AGPL §13); the default is upstream.
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
// src/lib/ios-push-shared.ts
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
},
},
};
+9
View File
@@ -639,6 +639,12 @@
"up_to_date": "Already up to date"
}
},
"push": {
"ios": {
"more_messages": "and {count} more",
"no_subject": "(no subject)"
}
},
"push_prompt": {
"body": "Turn on background notifications so new messages reach you while the app is closed.",
"dismiss": "Not now",
@@ -1008,6 +1014,8 @@
"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.",
"err_android_only": "Push notifications are only available on Android.",
"err_apns_empty": "iOS returned an empty push token.",
"err_apns_token": "iOS could not issue a push token ({detail}).",
"err_fcm_empty": "Firebase returned an empty device token.",
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
@@ -1015,6 +1023,7 @@
"err_no_account": "No account loaded - cannot set up push.",
"err_permission": "Notification permission was not granted.",
"err_relay_https": "The relay URL must use https://.",
"err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.",
"err_relay_rejected": "The push relay rejected the registration: {detail}",
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
"err_up_android_only": "UnifiedPush is only available on Android.",
-10
View File
@@ -33,14 +33,4 @@ if (process.env.BULWARK_MODULE_TIMINGS) {
}
}
// Evaluate a module when it is first used instead of all of them before the
// first render. Expo's default leaves this off; React Native's own template
// turns it on. Side-effect-only imports (`import './x'`) still run in place.
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
});
module.exports = config;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.83",
"version": "0.1.84",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.83",
"version": "0.1.84",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.83",
"version": "0.1.84",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+161
View File
@@ -0,0 +1,161 @@
import Foundation
/// The little JMAP the extension needs: who sent the new messages and what
/// they are about, plus the inbox's unread count for the badge.
struct PushedEmail {
let id: String
let threadId: String
let sender: String
let subject: String
let preview: String
}
struct JmapFetchResult {
let emails: [PushedEmail]
let inboxUnread: Int?
}
enum JmapError: Error {
case noCredentials
case http(Int)
case badResponse
}
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
struct StoredCredentials {
var json: [String: Any]
var username: String { json["username"] as? String ?? "" }
var password: String { json["password"] as? String ?? "" }
var accessToken: String? { json["accessToken"] as? String }
var refreshToken: String? { json["refreshToken"] as? String }
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
var clientId: String? { json["clientId"] as? String }
/// Milliseconds since 1970, like Date.now() in the app.
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
}
enum Jmap {
static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 12
config.timeoutIntervalForResource = 20
return URLSession(configuration: config)
}()
/// The Authorization header for these credentials, refreshing an OAuth
/// access token that is about to expire. A refreshed bundle is written back
/// to the shared keychain item so the app picks it up (and doesn't refresh
/// again with a refresh token the server has already replaced).
static func authorization(credentialsKey: String) async throws -> String {
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
var creds = StoredCredentials(json: json)
if let token = creds.accessToken {
let nowMs = Date().timeIntervalSince1970 * 1000
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
let url = URL(string: endpoint) {
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
creds.json["accessToken"] = fresh.accessToken
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
SharedKeychain.update(credentialsKey, json: creds.json)
return "Bearer \(fresh.accessToken)"
}
}
return "Bearer \(token)"
}
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
return "Basic \(basic)"
}
struct FreshTokens {
let accessToken: String
let refreshToken: String?
let expiresIn: Double?
}
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var form = URLComponents()
form.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: clientId),
]
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let access = body["access_token"] as? String else { throw JmapError.badResponse }
return FreshTokens(
accessToken: access,
refreshToken: body["refresh_token"] as? String,
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
)
}
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(authorization, forHTTPHeaderField: "Authorization")
let body: [String: Any] = [
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/get", [
"accountId": accountId,
"ids": Array(emailIds.prefix(5)),
"properties": ["threadId", "from", "subject", "preview"],
], "0"],
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
["Mailbox/get", [
"accountId": accountId,
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
"properties": ["unreadEmails"],
], "2"],
],
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
var emails: [PushedEmail] = []
var inboxUnread: Int?
for response in responses where response.count >= 2 {
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
// Keep the order the push gave (newest first from the server's filter).
// Stalwart can list an id twice; keep the first.
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
guard let id = item["id"] as? String else { return nil }
return (id, item)
}, uniquingKeysWith: { first, _ in first })
for id in emailIds {
guard let item = byId[id] else { continue }
let from = (item["from"] as? [[String: Any]])?.first
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let sender = name ?? (from?["email"] as? String) ?? ""
emails.append(PushedEmail(
id: id,
threadId: item["threadId"] as? String ?? "",
sender: sender,
subject: item["subject"] as? String ?? "",
preview: item["preview"] as? String ?? ""
))
}
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
}
}
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
}
}
+110
View File
@@ -0,0 +1,110 @@
import Foundation
import os
import UserNotifications
/// Ids and steps only, never credentials or message content.
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
/// Turns the relay's content-blind push ("New message" plus ids) into a real
/// notification: sender, subject and preview, fetched over JMAP on this
/// device with the account's own credentials. Nothing about the message ever
/// passes through the relay or Apple.
///
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
/// <credentialsKey> the account's stored credentials
/// If anything is missing or the server can't be reached, the relay's
/// placeholder text is shown, so the user still learns there is new mail.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var placeholder: UNMutableNotificationContent?
private var work: Task<Void, Never>?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
placeholder = content
let push = request.content.userInfo["body"] as? [String: Any]
work = Task { [weak self] in
let enriched = await NotificationService.enrich(content, push: push)
self?.finish(enriched)
}
}
override func serviceExtensionTimeWillExpire() {
work?.cancel()
finish(placeholder)
}
private func finish(_ content: UNNotificationContent?) {
guard let handler = contentHandler, let content else { return }
contentHandler = nil
handler(content)
}
static func sanitize(_ id: String) -> String {
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
}
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty else {
pushLog.error("push without an account id")
return content
}
pushLog.info("push for account \(accountId, privacy: .public), group \(SharedKeychain.group ?? "none", privacy: .public)")
guard let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
let credentialsKey = account["credentialsKey"] as? String,
let apiString = account["apiUrl"] as? String,
let apiUrl = URL(string: apiString) else {
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
return content
}
let emailIds = push["emailIds"] as? [String] ?? []
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
let strings = settings["strings"] as? [String: String] ?? [:]
content.threadIdentifier = accountId
// The app reads userInfo.body when the notification is tapped.
var body = push
do {
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
pushLog.info("fetched \(result.emails.count, privacy: .public) of \(emailIds.count, privacy: .public) messages")
if let first = result.emails.first {
content.title = first.sender
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
content.body = first.preview
if result.emails.count > 1 {
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
}
body["emailId"] = first.id
body["threadId"] = first.threadId
}
// Badge: like the app, the active account's inbox unread count. A push
// for another account leaves the badge as it is.
if settings["badge"] as? Bool == true,
settings["activeAccountId"] as? String == accountId,
let unread = result.inboxUnread {
content.badge = NSNumber(value: unread)
}
} catch {
// Keep the placeholder.
pushLog.error("fetch failed: \(String(describing: error), privacy: .public)")
}
var userInfo = content.userInfo
userInfo["body"] = body
content.userInfo = userInfo
return content
}
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
import Security
/// Items the app shares with this extension through a keychain access group
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
/// expo-secure-store: generic passwords whose account and generic attributes
/// are the UTF-8 key and whose service is "app:no-auth".
enum SharedKeychain {
static let service = "app:no-auth"
static var group: String? {
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
}
static func read(_ key: String) -> Data? {
guard let group else { return nil }
let account = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: group,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
pushLog.error("keychain read of \(key, privacy: .public) failed: \(status, privacy: .public)")
return nil
}
return item as? Data
}
static func readJSON(_ key: String) -> [String: Any]? {
guard let data = read(key) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
/// creates one: the app owns which items exist.
@discardableResult
static func update(_ key: String, json: [String: Any]) -> Bool {
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
let account = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: group,
]
let attributes: [String: Any] = [kSecValueData as String: data]
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
}
}
+195
View File
@@ -0,0 +1,195 @@
// Config plugin: push notifications on iOS.
//
// - The app's entitlements: aps-environment, and a keychain group shared with
// the notification service extension (the app's own group stays first, so
// items saved before keep their group).
// - A Notification Service Extension target ("NotificationService",
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
// only ids; the extension fetches sender and subject over JMAP on the device
// before iOS shows the notification. Its sources live next to this file and
// are copied into ios/ at prebuild, like everything else in ios/.
//
// Options (app.config.js):
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
// teamId: Apple team id, for the shared keychain group
// extensionProfile / appProfile: provisioning profile specifiers for the
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
// line applies a specifier to every target. Empty = leave unset.
const fs = require('node:fs');
const path = require('node:path');
const {
withEntitlementsPlist,
withXcodeProject,
withDangerousMod,
createRunOncePlugin,
} = require('@expo/config-plugins');
const TARGET = 'NotificationService';
const DEPLOYMENT_TARGET = '15.1';
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
function sharedGroupSuffix() {
return 'org.bulwarkmail.shared';
}
function withAppEntitlements(config, { apsEnvironment }) {
return withEntitlementsPlist(config, (cfg) => {
const bundleId = cfg.ios?.bundleIdentifier;
cfg.modResults['aps-environment'] = apsEnvironment;
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
cfg.modResults['keychain-access-groups'] = [...groups];
return cfg;
});
}
function infoPlist({ version, buildNumber }) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Bulwark Mail notifications</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${buildNumber}</string>
<key>BulwarkKeychainGroup</key>
<string>$(DEVELOPMENT_TEAM).${sharedGroupSuffix()}</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
`;
}
function entitlementsPlist() {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)${sharedGroupSuffix()}</string>
</array>
</dict>
</plist>
`;
}
function withExtensionFiles(config) {
return withDangerousMod(config, [
'ios',
async (cfg) => {
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
fs.mkdirSync(dir, { recursive: true });
for (const file of SOURCE_FILES) {
fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
}
fs.writeFileSync(
path.join(dir, `${TARGET}-Info.plist`),
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
);
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
return cfg;
},
]);
}
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
if (!appProfile && !extensionProfile) return;
const project = cfg.modResults;
const appName = `"${cfg.modRequest.projectName}"`;
const configs = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configs)) {
const entry = configs[key];
if (!entry.buildSettings || entry.name !== 'Release') continue;
const name = entry.buildSettings.PRODUCT_NAME;
const profile = name === `"${TARGET}"` ? extensionProfile
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
: '';
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
}
}
// One mod for creating the target and naming the profiles: separate
// withXcodeProject mods don't run in the order they are registered.
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
return withXcodeProject(config, (cfg) => {
addExtensionTarget(cfg, { teamId });
setReleaseProfiles(cfg, { appProfile, extensionProfile });
return cfg;
});
}
function addExtensionTarget(cfg, { teamId }) {
const project = cfg.modResults;
if (project.pbxTargetByName(TARGET)) return;
// addTarget expects these sections; a fresh Expo project may not have them.
const objects = project.hash.project.objects;
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
const group = project.addPbxGroup(
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
TARGET,
TARGET,
);
// Hang the group off the project's main group.
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
project.addToPbxGroup(group.uuid, mainGroupId);
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
const configs = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configs)) {
const entry = configs[key];
if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
const s = entry.buildSettings;
s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
s.TARGETED_DEVICE_FAMILY = '"1,2"';
s.SWIFT_VERSION = '5.0';
s.CLANG_ENABLE_MODULES = 'YES';
s.GENERATE_INFOPLIST_FILE = 'NO';
if (teamId) s.DEVELOPMENT_TEAM = teamId;
}
}
function withIosPush(config, options = {}) {
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
config = withAppEntitlements(config, { apsEnvironment });
config = withExtensionFiles(config);
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
return config;
}
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
@@ -418,6 +418,40 @@ describe('JMAPClient hardening', () => {
expect(client.authHeader).toBe('Bearer new');
});
// On iOS the notification extension refreshes OAuth tokens too and writes
// the new bundle back. Refreshing again with the refresh token in memory
// would spend a token the server has already replaced.
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
const SecureStore = await import('expo-secure-store');
const client = new JMAPClient();
mockFetch([{ status: 200, json: SESSION }]);
await client.connectWithOAuth('https://mail.example.com', {
accessToken: 'old',
refreshToken: 'r1',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
serverUrl: 'https://mail.example.com',
username: 'user@example.com',
password: '',
accessToken: 'from-extension',
refreshToken: 'r2',
expiresAt: Date.now() + 7_200_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
}));
const fetch = mockFetch([
{ status: 401 },
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
]);
await client.request([['Mailbox/get', {}, '0']]);
expect(mockRefresh).not.toHaveBeenCalled();
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
});
it('updatePassword rewrites the stored credential', async () => {
const SecureStore = await import('expo-secure-store');
const client = await connected();
@@ -1,130 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const secure = new Map<string, string>();
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { JMAPClient, AuthenticationError } from '../jmap-client';
import type { JMAPSession } from '../types';
// Start-up used to wait for the server's session document before the
// account could show. The client now keeps the last one per account and
// starts from it (loadAccountFromCache), then fetches a fresh one.
const ACCOUNT = 'user@mail.example.com';
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
const SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
state: 'state-1',
};
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
let i = 0;
const fn = vi.fn(async (url: string) => {
const r = responses[i++] ?? responses[responses.length - 1];
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: r.status === 200 ? 'OK' : 'Error',
url,
redirected: false,
headers: { get: () => null },
json: async () => r.json,
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
};
});
vi.stubGlobal('fetch', fn);
return fn;
}
async function storeCredentials() {
const SecureStore = await import('expo-secure-store');
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
}
beforeEach(async () => {
secure.clear();
await AsyncStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('saved session', () => {
it('starts from the session of the last connect, without a request', async () => {
await storeCredentials();
mockFetch([{ status: 200, json: SESSION }]);
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
const fetch = mockFetch([{ status: 500 }]);
const client = new JMAPClient();
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
expect(fetch).not.toHaveBeenCalled();
expect(client.accountId).toBe('acc-1');
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
});
it('has nothing to start from before the first connect', async () => {
await storeCredentials();
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('ignores a session saved for another server', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
);
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('is dropped with the credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
await new JMAPClient().clearAccountCredentials(ACCOUNT);
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
});
it('revalidates against the server and reports refused credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 401 }]);
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
});
it('drops a revalidation that finishes after a switch to another account', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
const pending = client.revalidateSession();
client.reset();
expect(await pending).toBeNull();
expect(client.currentSession).toBeNull();
});
});
+47 -104
View File
@@ -1,5 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
JMAPSession,
JMAPMethodCall,
@@ -16,6 +15,7 @@ import {
type OAuthTokenSource,
} from '../lib/oauth';
import { FirstTouchGate } from './first-touch-gate';
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
// Refresh OAuth access tokens this many ms before they actually expire so
@@ -41,24 +41,10 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
function credentialsKey(accountId: string): string {
export function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
// The session document from the account's last connect (URLs, account ids,
// capabilities; no secrets). Start-up uses it to show the account without
// waiting for the network, then fetches a fresh one (revalidateSession).
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
function sessionCacheKey(accountId: string): string {
return SESSION_CACHE_PREFIX + accountId;
}
interface CachedSession {
serverUrl: string;
session: JMAPSession;
}
export interface StoredCredentials {
serverUrl: string;
username: string;
@@ -275,10 +261,7 @@ export class JMAPClient {
this.firstTouchGate.reset();
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
return this.session;
}
@@ -357,10 +340,7 @@ export class JMAPClient {
this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
return { session: this.session, username, accountId };
}
@@ -382,15 +362,36 @@ export class JMAPClient {
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
}
/**
* Take the stored OAuth tokens when they are newer than the ones in memory:
* on iOS the notification extension refreshes them too and writes the new
* bundle back. True when the in-memory tokens changed.
*/
private async adoptStoredTokens(): Promise<boolean> {
const current = this.credentials;
if (!current?.accessToken || !current.refreshToken) return false;
const accountId = generateAccountId(current.username, current.serverUrl);
const stored = await this.getStoredCredentials(accountId).catch(() => null);
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
this.credentials = {
...current,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken ?? current.refreshToken,
expiresAt: stored.expiresAt,
};
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
return true;
}
private currentOAuthTokens(): OAuthTokens | null {
if (
!this.credentials?.accessToken ||
@@ -429,10 +430,17 @@ export class JMAPClient {
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
// header they capture is not about to expire.
async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens();
let tokens = this.currentOAuthTokens();
if (!tokens) return;
if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
// Another process may have refreshed already (the iOS notification
// extension); use its tokens rather than spend a refresh token the
// server has replaced.
if (await this.adoptStoredTokens()) {
tokens = this.currentOAuthTokens();
if (!tokens || (tokens.expiresAt != null && tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
}
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
@@ -448,6 +456,10 @@ export class JMAPClient {
// the account instead of evicting it (webmail 1.7.6 "keep the session when
// the auth server is briefly unreachable").
async forceRefreshToken(): Promise<boolean> {
if (!this.currentOAuthTokens()) return false;
// A newer token in storage (refreshed by the iOS notification extension)
// is worth a retry before refreshing again.
if (await this.adoptStoredTokens()) return true;
const tokens = this.currentOAuthTokens();
if (!tokens) return false;
try {
@@ -474,10 +486,7 @@ export class JMAPClient {
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
}
// Legacy single-slot restore - kept for backward-compat tests. New code
@@ -505,6 +514,9 @@ export class JMAPClient {
async loadAccount(accountId: string): Promise<boolean> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
// iOS: credentials saved before the notification extension existed move
// to the keychain group it can read (a no-op elsewhere and afterwards).
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
let creds: StoredCredentials;
try {
@@ -539,74 +551,9 @@ export class JMAPClient {
);
}
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(accountId);
return true;
}
/**
* Start-up without a network round trip: the account's credentials and the
* session document saved at its last connect. Returns false when either is
* missing (or the saved one is for another server); the caller then uses
* loadAccount. A wrong password or revoked token surfaces on the first
* request (401, onAuthFailure) or in revalidateSession.
*/
async loadAccountFromCache(accountId: string): Promise<boolean> {
const [stored, cachedRaw] = await Promise.all([
SecureStore.getItemAsync(credentialsKey(accountId)),
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
]);
if (!stored || !cachedRaw) return false;
let creds: StoredCredentials;
let cached: CachedSession;
try {
creds = JSON.parse(stored);
cached = JSON.parse(cachedRaw);
} catch {
return false;
}
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
let accountIdInSession: string;
try {
accountIdInSession = this.resolveAccountId(cached.session);
} catch {
return false;
}
this.credentials = creds;
this.firstTouchGate.reset();
this.session = cached.session;
this._accountId = accountIdInSession;
return true;
}
/**
* Fetch a fresh session document for the account the client holds now,
* after a start from the saved one. Resolves null when the client moved to
* another account meanwhile (the result is dropped). Throws
* AuthenticationError / NetworkError like loadAccount.
*/
async revalidateSession(): Promise<JMAPSession | null> {
const creds = this.credentials;
if (!creds) return null;
let fresh: JMAPSession;
try {
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
} catch (err) {
if (this.credentials !== creds) return null;
throw err;
}
if (this.credentials !== creds) return null;
this.session = fresh;
this._accountId = this.resolveAccountId(fresh);
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
return fresh;
}
private rememberSession(accountId: string): void {
if (!this.session || !this.credentials) return;
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
}
/**
* Snapshot of the live connection so a failed "add account" / switch can put
* the previous account back without a network round-trip.
@@ -628,14 +575,12 @@ export class JMAPClient {
async clearAccountCredentials(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId));
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
}
async clearAllCredentials(accountIds: string[]): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
]);
}
@@ -648,7 +593,7 @@ export class JMAPClient {
const creds: StoredCredentials = JSON.parse(stored);
// Re-save under the per-account key before dropping the legacy entry
const accountId = generateAccountId(creds.username, creds.serverUrl);
await SecureStore.setItemAsync(credentialsKey(accountId), stored);
await writeSharedSecret(credentialsKey(accountId), stored);
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
return creds;
} catch {
@@ -698,7 +643,6 @@ export class JMAPClient {
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
if (currentAccountId) {
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
}
}
@@ -918,7 +862,6 @@ export class JMAPClient {
const baseUrl = this.credentials.serverUrl;
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
return this.session;
}
@@ -1243,7 +1186,7 @@ export class JMAPClient {
}
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
+36
View File
@@ -1,21 +1,57 @@
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { useAuthStore } from '../stores/auth-store';
import { appBadgeCount, setAppBadge } from '../lib/app-badge';
import { jmapClient } from '../api/jmap-client';
import { writeIosPushSettings } from '../lib/ios-push-shared';
import { shareAccountWithExtension } from '../lib/push-notifications';
/**
* Keeps the app icon badge on the active account's inbox unread count (see
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
* account signed out, so the badge is cleared then. Renders nothing.
*
* On iOS it also tells the notification extension (plugins/ios-push) which
* account the badge follows and the few texts it writes itself, so a push
* that arrives while the app is closed updates the badge the same way.
*/
export function AppIconBadge() {
const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// The JMAP session comes up after the account is known (offline start).
const haveSession = useAuthStore((s) => s.session != null);
const t = useLocaleStore((s) => s.t);
useEffect(() => {
void setAppBadge(count);
}, [count]);
useEffect(() => {
if (Platform.OS !== 'ios') return;
let jmapAccountId: string | null = null;
try {
jmapAccountId = jmapClient.accountId;
} catch {
jmapAccountId = null;
}
// The extension learns how to reach the active account as soon as it is
// signed in, so a push for it shows sender and subject even before (or
// without) this device's own push setup, e.g. after a reinstall.
if (activeAccountId) void shareAccountWithExtension(activeAccountId, jmapAccountId);
void writeIosPushSettings({
badge: enabled,
activeAccountId: jmapAccountId,
strings: {
noSubject: t('push.ios.no_subject', '(no subject)'),
more: t('push.ios.more_messages', 'and {count} more'),
},
}).catch(() => undefined);
}, [enabled, activeAccountId, haveSession, t]);
useEffect(() => () => { void setAppBadge(0); }, []);
return null;
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { iosTapPayload } from '../push-notifications';
// A tapped iOS mail notification: the relay's ids arrive as the
// notification's data (APNs `body`), the notification extension adds the
// message it showed, and the app opens that message under the account whose
// push setup recorded the JMAP account id.
describe('iosTapPayload', () => {
beforeEach(async () => {
await AsyncStorage.clear();
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' }));
});
it('opens the message the extension showed, in the right account', async () => {
const payload = await iosTapPayload({
kind: 'jmap-email-push',
accountId: 'c',
emailIds: ['e1', 'e2'],
emailId: 'e2',
threadId: 't2',
});
expect(payload).toEqual({ emailId: 'e2', threadId: 't2', accountId: 'ada@mail.example.org', jmapAccountId: 'c' });
});
it('falls back to the first pushed id when the extension could not fetch', async () => {
const payload = await iosTapPayload({ kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] });
expect(payload?.emailId).toBe('e1');
expect(payload?.threadId).toBe('');
});
it('leaves calendar reminders and other local notifications alone', async () => {
expect(await iosTapPayload({ eventId: 'ev1', calendarId: 'cal' })).toBeNull();
expect(await iosTapPayload(null)).toBeNull();
});
it('does nothing for a push without a message id', async () => {
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
});
});
+118
View File
@@ -0,0 +1,118 @@
// What the app shares with the iOS notification service extension
// (plugins/ios-push): account credentials, where to reach each pushed account,
// and the badge counts. They live in a keychain access group both targets
// belong to; nothing else of the app is visible to the extension.
//
// Readable after the first unlock: a push usually arrives while the phone is
// locked, and a notification that has to wait for the user to unlock would
// show only "New message".
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type Extra = { iosSharedKeychainGroup?: unknown; iosAppKeychainGroup?: unknown; apnsEnvironment?: unknown };
// Loaded on first use, on iOS only: the module pulls in native code that
// Android's headless tasks and the unit tests can do without.
function extra(): Extra {
const { default: Constants } = require('expo-constants') as typeof import('expo-constants');
return (Constants.expoConfig?.extra ?? {}) as Extra;
}
/** The APNs host this build's device tokens belong to. */
export function apnsEnvironment(): 'production' | 'sandbox' {
return extra().apnsEnvironment === 'sandbox' ? 'sandbox' : 'production';
}
/** The access group shared with the extension, or null off iOS. */
export function sharedKeychainGroup(): string | null {
if (Platform.OS !== 'ios') return null;
const group = extra().iosSharedKeychainGroup;
return typeof group === 'string' && group ? group : null;
}
/** Options for an item the extension must read. Undefined off iOS. */
export function sharedStoreOptions(): SecureStore.SecureStoreOptions | undefined {
const group = sharedKeychainGroup();
if (!group) return undefined;
return { accessGroup: group, keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK };
}
/** Options naming the app's own access group, where items used to live. */
export function appStoreOptions(): SecureStore.SecureStoreOptions | undefined {
if (Platform.OS !== 'ios') return undefined;
const group = extra().iosAppKeychainGroup;
return typeof group === 'string' && group ? { accessGroup: group } : undefined;
}
// SecureStore keys: letters, digits, ".", "-", "_" only. The extension
// builds the same key from the push's account id (NotificationService.swift).
function keyPart(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, '_');
}
export function pushAccountKey(jmapAccountId: string): string {
return `bulwark_push_account__${keyPart(jmapAccountId)}`;
}
export const PUSH_SETTINGS_KEY = 'bulwark_push_settings';
export interface IosPushAccount {
/** SecureStore key of the account's credentials. */
credentialsKey: string;
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
apiUrl: string;
}
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options);
}
export async function removeIosPushAccount(jmapAccountId: string): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.deleteItemAsync(pushAccountKey(jmapAccountId), options).catch(() => undefined);
}
export interface IosPushSettings {
/** Show the unread count on the app icon. */
badge: boolean;
/** JMAP id of the active account: the badge is its inbox unread count. */
activeAccountId: string | null;
/** The few texts the extension writes itself, in the app's language. */
strings: { noSubject: string; more: string };
}
export async function writeIosPushSettings(settings: IosPushSettings): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.setItemAsync(PUSH_SETTINGS_KEY, JSON.stringify(settings), options);
}
/**
* Store a credentials item where the extension can read it. Written to the
* shared group first, then removed from the app's own group, so a crash in
* between leaves two copies rather than none. Off iOS a plain write.
*/
export async function writeSharedSecret(key: string, value: string): Promise<void> {
const shared = sharedStoreOptions();
if (!shared) {
await SecureStore.setItemAsync(key, value);
return;
}
await SecureStore.setItemAsync(key, value, shared);
const own = appStoreOptions();
if (own) await SecureStore.deleteItemAsync(key, own).catch(() => undefined);
}
/** Move an item saved before the shared group existed. True if it moved. */
export async function moveToSharedGroup(key: string): Promise<boolean> {
const own = appStoreOptions();
if (!own || !sharedStoreOptions()) return false;
const value = await SecureStore.getItemAsync(key, own).catch(() => null);
if (value === null) return false;
await writeSharedSecret(key, value);
return true;
}
+164 -4
View File
@@ -29,6 +29,9 @@ import {
persistedAccounts,
} from '../device-sync/app/prefs';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types';
import * as Notifications from 'expo-notifications';
import { credentialsKey } from '../api/jmap-client';
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
import {
getUnifiedPushDistributors,
isUnifiedPushSupported,
@@ -524,12 +527,12 @@ 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 only). */
/** True on platforms that have a push transport wired up: FCM or UnifiedPush on Android, APNs on iOS. */
export function isPushSupported(): boolean {
return getNative() !== null || isUnifiedPushSupported();
return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported();
}
export type PushTransport = 'fcm' | 'unifiedpush';
export type PushTransport = 'fcm' | 'unifiedpush' | 'apns';
export async function getStoredPushTransport(): Promise<PushTransport | null> {
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
@@ -545,6 +548,8 @@ export async function setStoredPushTransport(transport: PushTransport | null): P
}
export async function getEffectivePushTransport(): Promise<PushTransport> {
// iOS has one way in: APNs, through the relay.
if (Platform.OS === 'ios') return 'apns';
return (await getStoredPushTransport()) ?? 'fcm';
}
@@ -637,6 +642,14 @@ export async function dismissPushPrompt(accountId: string): Promise<void> {
}
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS === 'ios') {
const current = await Notifications.getPermissionsAsync();
if (current.granted) return true;
const asked = await Notifications.requestPermissionsAsync({
ios: { allowAlert: true, allowBadge: true, allowSound: true },
});
return asked.granted;
}
if (Platform.OS !== 'android') return true;
if (Platform.Version < 33) return true;
const status = await PermissionsAndroid.request(
@@ -688,6 +701,26 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
return token;
}
// The APNs device token (hex). iOS hands it out once notifications are
// allowed; expo-notifications asks the OS for it.
async function getApnsTokenOrThrow(): Promise<string> {
let token: string | null = null;
try {
const result = await Notifications.getDevicePushTokenAsync();
token = typeof result.data === 'string' ? result.data : null;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'token',
t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }),
);
}
if (!token) {
throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push token.'));
}
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
@@ -778,6 +811,64 @@ async function registerWithRelay(params: {
}
}
async function registerWithRelayApns(params: {
relayBaseUrl: string;
subscriptionId: string;
deviceToken: string;
accountLabel?: string;
}): Promise<void> {
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
deviceToken: params.deviceToken,
environment: apnsEnvironment(),
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', {
url: params.relayBaseUrl,
detail,
}),
);
}
if (res.status === 503) {
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', {
url: params.relayBaseUrl,
}),
);
}
if (!res.ok) {
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', {
detail: await readRelayError(res),
}),
);
}
}
/**
* Tell the iOS notification extension how to reach an account: which stored
* credentials to use and the JMAP API URL. Without it the extension shows the
* relay's placeholder text.
*/
export async function shareAccountWithExtension(accountId: string, jmapAccountId: string | null): Promise<void> {
if (Platform.OS !== 'ios' || !jmapAccountId) return;
const apiUrl = jmapClient.currentSession?.apiUrl;
if (!apiUrl) return;
await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined);
}
async function registerWithRelayUnifiedPush(params: {
relayBaseUrl: string;
subscriptionId: string;
@@ -948,6 +1039,9 @@ async function setupPushNotificationsInner(
if (transport === 'fcm' && !native) {
throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.'));
}
if (transport === 'apns' && Platform.OS !== 'ios') {
throw new PushSetupError('platform', 'APNs is only available on iOS.');
}
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.'));
}
@@ -967,8 +1061,11 @@ async function setupPushNotificationsInner(
logPhase('token', transport);
let fcmToken: string | null = null;
let upEndpoint: UnifiedPushEndpoint | null = null;
let apnsToken: string | null = null;
if (transport === 'unifiedpush') {
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
} else if (transport === 'apns') {
apnsToken = await getApnsTokenOrThrow();
} else {
fcmToken = await getFcmTokenOrThrow(native!);
}
@@ -993,7 +1090,14 @@ async function setupPushNotificationsInner(
// individually and tags it with the JMAP account id so the headless task
// can route it.
logPhase('relay', relayBaseUrl);
if (upEndpoint) {
if (apnsToken) {
await registerWithRelayApns({
relayBaseUrl,
subscriptionId: deviceClientId,
deviceToken: apnsToken,
accountLabel: params.accountLabel,
});
} else if (upEndpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl,
subscriptionId: deviceClientId,
@@ -1043,6 +1147,7 @@ async function setupPushNotificationsInner(
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
await shareAccountWithExtension(accountId, jmapAccountId);
await AsyncStorage.removeItem(optedOutKey(accountId));
logPhase('done', 'reused existing subscription');
return { subscriptionId: storedServerId, verified: true };
@@ -1125,6 +1230,7 @@ async function setupPushNotificationsInner(
}
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
await shareAccountWithExtension(accountId, jmapAccountId);
await AsyncStorage.removeItem(optedOutKey(accountId));
logPhase('done', 'subscription verified');
@@ -1196,6 +1302,10 @@ async function deregisterFromRelay(
}
async function clearAccountPushKeys(accountId: string): Promise<void> {
if (Platform.OS === 'ios') {
const jmapAccountId = (await readPushJmapAccountIds())[accountId];
if (jmapAccountId) await removeIosPushAccount(jmapAccountId);
}
await AsyncStorage.multiRemove([
subscriptionIdKey(accountId),
deviceClientIdKey(accountId),
@@ -1509,6 +1619,17 @@ export function notificationTapJmapAccountId(payload: NotificationTapPayload): s
// before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
if (Platform.OS === 'ios') {
const response = await Notifications.getLastNotificationResponseAsync().catch(() => null);
if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null;
const handledKey = 'push:lastHandledResponse:v1';
const id = response.notification.request.identifier;
// The last response survives app restarts; open it once.
if ((await AsyncStorage.getItem(handledKey)) === id) return null;
const payload = await iosTapPayload(response.notification.request.content.data);
if (payload) await AsyncStorage.setItem(handledKey, id);
return payload;
}
if (Platform.OS !== 'android') return null;
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
@@ -1524,8 +1645,47 @@ export async function getInitialNotificationTap(): Promise<NotificationTapPayloa
export function addNotificationTapListener(
listener: (payload: NotificationTapPayload) => void,
): () => void {
if (Platform.OS === 'ios') {
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
const id = response.notification.request.identifier;
void iosTapPayload(response.notification.request.content.data).then((payload) => {
if (!payload) return;
void AsyncStorage.setItem('push:lastHandledResponse:v1', id);
listener(payload);
});
});
return () => sub.remove();
}
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:notificationTap', listener);
return () => sub.remove();
}
/**
* A tapped iOS mail notification as the app opens it. The relay puts the
* JMAP account and message ids into the payload's `body`, which
* expo-notifications hands over as `data`; the notification extension adds
* the message (and thread) it showed. Calendar reminders and other local
* notifications carry no `kind` and are left to their own handlers.
*/
export async function iosTapPayload(data: unknown): Promise<NotificationTapPayload | null> {
if (!data || typeof data !== 'object') return null;
const d = data as Record<string, unknown>;
if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null;
const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : '';
const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : [];
const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0];
if (!emailId) return null;
// The local account the subscription belongs to: the one whose push
// setup recorded this JMAP account id.
const byLocal = await readPushJmapAccountIds();
const accountId = Object.keys(byLocal).find((local) => byLocal[local] === jmapAccountId);
return {
emailId,
threadId: typeof d.threadId === 'string' ? d.threadId : '',
accountId,
jmapAccountId: jmapAccountId || undefined,
};
}
+1 -60
View File
@@ -6,8 +6,6 @@ vi.mock('../../api/jmap-client', () => ({
logout: vi.fn(),
restoreSession: vi.fn(),
loadAccount: vi.fn(),
loadAccountFromCache: vi.fn(async () => false),
revalidateSession: vi.fn(async () => null),
consumeLegacyCredentials: vi.fn(async () => null),
clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined),
@@ -37,7 +35,7 @@ vi.mock('../../lib/push-notifications', () => ({
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
import { jmapClient } from '../../api/jmap-client';
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
import { useAccountStore } from '../account-store';
import { useCalendarStore } from '../calendar-store';
@@ -51,8 +49,6 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights';
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
function resetAccountStore(): void {
useAccountStore.setState({
@@ -240,61 +236,6 @@ describe('auth-store', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
describe('from the session saved at the last connect', () => {
function registerAccount() {
useAccountStore.setState({
accounts: [{
id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user',
displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0,
isConnected: true, hasError: false, isDefault: true,
}],
activeAccountId: 'acc-1',
defaultAccountId: 'acc-1',
});
}
// Start-up used to wait for the server's session document before the
// cached inbox could show.
it('shows the account without waiting for the server', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockReturnValueOnce(new Promise(() => undefined));
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
expect(mockLoadAccount).not.toHaveBeenCalled();
expect(mockRevalidate).toHaveBeenCalledTimes(1);
});
it('signs out when the server refuses the stored credentials', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(useAuthStore.getState().error).toBe('Session expired');
expect(useAccountStore.getState().accounts).toEqual([]);
});
it('stays signed in and marks the account offline without network', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new Error('Network request failed'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true));
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAccountStore.getState().accounts[0].isConnected).toBe(false);
});
});
it('stops waiting for a persisted store that never finishes hydrating', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
-47
View File
@@ -811,24 +811,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// data from a previous session.
useEmailStore.getState().setActiveAccount(target.id);
// Start from the session saved at the last connect: the cached mail
// shows without waiting for the server, and a fresh session follows.
let fromCache = false;
try {
fromCache = await jmapClient.loadAccountFromCache(target.id);
} catch {
fromCache = false;
}
if (fromCache) {
accountStore.setActiveAccount(target.id);
applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id);
perfMark('restore-done', { cached: true });
refetchFeatureStores();
void revalidateRestoredSession(target.id);
void syncAccountDisplayName(target.id);
return true;
}
try {
const ok = await jmapClient.loadAccount(target.id);
perfMark('restore-session');
@@ -947,35 +929,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
clearError: () => set({ error: null }),
}));
/**
* After a start from the saved session: fetch a fresh one. New capabilities
* or URLs replace the saved ones; bad credentials go the way of any 401 on a
* live session (retrySession, then the sign-in screen); no network marks the
* account offline, as a start without network always did.
*/
async function revalidateRestoredSession(accountId: string): Promise<void> {
const before = jmapClient.accountId;
try {
const fresh = await jmapClient.revalidateSession();
if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return;
useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId });
useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
if (jmapClient.accountId !== before) refetchFeatureStores();
} catch (err) {
if (useAuthStore.getState().activeAccountId !== accountId) return;
if (err instanceof AuthenticationError) {
useAuthStore.setState({ session: null });
await useAuthStore.getState().retrySession();
return;
}
useAccountStore.getState().updateAccount(accountId, {
isConnected: false,
hasError: true,
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
// A 401 on a live session (revoked password/token, expired refresh token)
// used to leave the user on a dead session until relaunch: nothing outside
// the login flow handled `AuthenticationError`, and `retrySession` bailed