From e35cd82f5f4dab0c8edde626512aecb83825f175 Mon Sep 17 00:00:00 2001 From: Michael Brusegard <56915010+michaelbrusegard@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:01:58 -0700 Subject: [PATCH 01/12] fix(auth): verify external identity providers during handoff Allow webmail-configured OAuth providers on separate hosts without trusting callback-supplied endpoints or broadly allowing sibling domains. Bind the client, JMAP server, issuer and token endpoint to HTTPS metadata from the selected webmail for both handoff and pairing. --- src/lib/__tests__/oauth-handoff.test.ts | 164 ++++++++++++++++++++++++ src/lib/oauth.ts | 62 ++++++++- 2 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/oauth-handoff.test.ts diff --git a/src/lib/__tests__/oauth-handoff.test.ts b/src/lib/__tests__/oauth-handoff.test.ts new file mode 100644 index 00000000..328cc9b9 --- /dev/null +++ b/src/lib/__tests__/oauth-handoff.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as WebBrowser from 'expo-web-browser'; + +const { mockSecureFetch } = vi.hoisted(() => ({ mockSecureFetch: vi.fn() })); +vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch })); + +import { redeemPairingCode, runWebmailHandoff } from '../oauth'; + +const WEBMAIL = 'https://webmail.example.com'; +const SERVER = 'https://mail.example.com'; +const ISSUER = 'https://id.example.net'; +const TOKEN_ENDPOINT = `${ISSUER}/token`; + +let config: Record; +let metadata: Record; +let bundle: Record; +let stateMismatch: boolean; + +function response(url: string, data: unknown): Response { + return { ok: true, status: 200, url, redirected: false, json: async () => data } as Response; +} + +beforeEach(() => { + vi.resetAllMocks(); + config = { + jmapServerUrl: SERVER, + oauthEnabled: true, + oauthClientId: 'stalwart', + oauthIssuerUrl: ISSUER, + }; + metadata = { issuer: ISSUER, token_endpoint: TOKEN_ENDPOINT }; + bundle = { + flow: 'oauth', server_url: SERVER, access_token: 'test-access', + refresh_token: 'test-refresh', client_id: 'stalwart', token_endpoint: TOKEN_ENDPOINT, + }; + stateMismatch = false; + vi.mocked(WebBrowser.openAuthSessionAsync).mockImplementation(async (url) => ({ + type: 'success', + url: `bulwarkmobile://auth/callback#${new URLSearchParams({ + ...bundle, + state: stateMismatch ? 'wrong-state' : new URL(url).searchParams.get('mobile_state')!, + })}`, + })); + mockSecureFetch.mockImplementation(async (url: string) => { + if (url === `${WEBMAIL}/api/auth/pair/redeem`) return response(url, bundle); + if (url === `${WEBMAIL}/api/config`) return response(url, config); + if (url === `${WEBMAIL}/api/auth/oauth/metadata`) return response(url, metadata); + throw new Error(`Unexpected fetch: ${url}`); + }); +}); + +afterEach(() => vi.useRealTimers()); + +for (const flow of ['handoff', 'pairing'] as const) { + const login = () => flow === 'handoff' + ? runWebmailHandoff(WEBMAIL) + : redeemPairingCode(WEBMAIL, 'test-pair-code'); + + describe(`${flow} external identity provider`, () => { + it('accepts an external token endpoint verified through the selected webmail', async () => { + await expect(login()).resolves.toMatchObject({ + flow: 'oauth', serverUrl: SERVER, + tokens: { tokenEndpoint: TOKEN_ENDPOINT, clientId: 'stalwart', source: flow }, + }); + const discoveryCalls = mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem')); + expect(discoveryCalls.map(([url]) => url)).toEqual([ + `${WEBMAIL}/api/config`, `${WEBMAIL}/api/auth/oauth/metadata`, + ]); + for (const [, options] of discoveryCalls) { + expect(options).toMatchObject({ + headers: { Accept: 'application/json' }, redirect: 'error', timeoutMs: 8000, + }); + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(options.body).toBeUndefined(); + } + }); + + it('also accepts a configured sibling-host provider', async () => { + config.oauthIssuerUrl = metadata.issuer = 'https://id.example.com'; + metadata.token_endpoint = bundle.token_endpoint = 'https://id.example.com/token'; + await expect(login()).resolves.toMatchObject({ flow: 'oauth' }); + }); + + it('keeps same-host login independent of webmail metadata', async () => { + bundle.token_endpoint = `${SERVER}/auth/token`; + await expect(login()).resolves.toMatchObject({ flow: 'oauth' }); + expect(mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem'))).toHaveLength(0); + }); + + const mismatches: [string, () => void][] = [ + ['substituted token endpoint', () => { bundle.token_endpoint = 'https://attacker.test/token'; }], + ['lookalike token hostname', () => { bundle.token_endpoint = 'https://id.example.net.attacker.test/token'; }], + ['different token path', () => { bundle.token_endpoint = `${ISSUER}/other`; }], + ['different token query', () => { bundle.token_endpoint = `${TOKEN_ENDPOINT}?other=1`; }], + ['token URL with credentials', () => { bundle.token_endpoint = metadata.token_endpoint = 'https://user@id.example.net/token'; }], + ['token URL with a fragment', () => { bundle.token_endpoint = metadata.token_endpoint = `${TOKEN_ENDPOINT}#other`; }], + ['cleartext token endpoint', () => { bundle.token_endpoint = metadata.token_endpoint = 'http://id.example.net/token'; }], + ['different client', () => { bundle.client_id = 'different-client'; }], + ['different JMAP server', () => { bundle.server_url = 'https://other.example.com'; }], + ['disabled OAuth', () => { config.oauthEnabled = false; }], + ['non-boolean OAuth flag', () => { config.oauthEnabled = 'true'; }], + ['missing issuer', () => { delete config.oauthIssuerUrl; }], + ['cleartext issuer', () => { config.oauthIssuerUrl = metadata.issuer = 'http://id.example.net'; }], + ['issuer mismatch', () => { metadata.issuer = 'https://other-id.example.net'; }], + ['missing token endpoint', () => { delete metadata.token_endpoint; }], + ]; + it.each(mismatches)('rejects %s', async (_name, mutate) => { + mutate(); + await expect(login()).rejects.toThrow('token endpoint is not trusted'); + }); + + it.each(['/api/config', '/api/auth/oauth/metadata'])('fails closed when %s cannot be fetched', async (path) => { + const normal = mockSecureFetch.getMockImplementation()!; + mockSecureFetch.mockImplementation((url, options) => { + if (url.endsWith(path)) throw new Error('Network unavailable'); + return normal(url, options); + }); + await expect(login()).rejects.toThrow('token endpoint is not trusted'); + }); + + const badResponses: [string, Partial][] = [ + ['HTTP error', { ok: false, status: 503 }], + ['cross-origin redirect', { url: 'https://attacker.test/api/config', redirected: true }], + ['same-origin redirect', { url: `${WEBMAIL}/different`, redirected: true }], + ['redirect back to the requested URL', { url: `${WEBMAIL}/api/config`, redirected: true }], + ['missing final URL (native client-certificate bridge)', { url: '' }], + ['invalid JSON', { json: async () => { throw new SyntaxError('Invalid JSON'); } }], + ]; + it.each(badResponses)('fails closed for metadata with %s', async (_name, overrides) => { + const normal = mockSecureFetch.getMockImplementation()!; + mockSecureFetch.mockImplementation(async (url, options) => { + const result = await normal(url, options); + return url.endsWith('/pair/redeem') ? result : { ...result, ...overrides }; + }); + await expect(login()).rejects.toThrow('token endpoint is not trusted'); + }); + + it('aborts metadata discovery after its deadline', async () => { + vi.useFakeTimers(); + const normal = mockSecureFetch.getMockImplementation()!; + mockSecureFetch.mockImplementation((url, options) => { + if (!url.endsWith('/api/config')) return normal(url, options); + return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('Aborted')))); + }); + const result = expect(login()).rejects.toThrow('token endpoint is not trusted'); + await vi.advanceTimersByTimeAsync(8000); + await result; + }); + }); +} + +it('rejects a mismatched callback state before fetching metadata', async () => { + stateMismatch = true; + await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch'); + expect(mockSecureFetch).not.toHaveBeenCalled(); +}); + +it('leaves password handoff unchanged', async () => { + bundle = { flow: 'password', server_url: SERVER, username: 'user@example.com', password: 'test-password' }; + await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({ + flow: 'password', serverUrl: SERVER, username: 'user@example.com', password: 'test-password', + }); + expect(mockSecureFetch).not.toHaveBeenCalled(); +}); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 31ba28d2..01759fc4 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -63,8 +63,8 @@ function randomState(): string { } // Anything from the redirect fragment is attacker-influenced: another app can -// register the same custom scheme. Only accept https endpoints, and only a -// token endpoint that lives on the mail server's own host (or the webmail's). +// register the same custom scheme. External token endpoints need verification +// through the selected webmail, rather than trusting the redirect alone. function isHttpsUrl(value: string | null): value is string { return !!value && /^https:\/\/[^/?#\s]+/i.test(value); } @@ -92,6 +92,60 @@ export function isAcceptableTokenEndpoint( return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`)); } +// External IdPs must be authorized by the selected webmail over TLS, never +// by callback fields or a shared DNS suffix. Bind all returned identifiers. +async function isTrustedHandoffEndpoint( + tokenEndpoint: string, + serverUrl: string, + webmailUrl: string, + clientId: string, +): Promise { + if (isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) return true; + function httpsUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.hash; + } catch { + return false; + } + } + if (!httpsUrl(webmailUrl) || !httpsUrl(tokenEndpoint) || !httpsUrl(serverUrl)) return false; + const base = webmailUrl.replace(/\/+$/, ''); + try { + const load = async (path: string) => { + const url = `${base}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await secureFetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: controller.signal, + timeoutMs: 8000, + }); + // The native client-certificate bridge omits the final URL. Without + // transport evidence of its destination, external-IdP trust must fail. + if (!response.ok || response.url !== url || response.redirected) { + throw new Error('Untrusted metadata response'); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } + }; + const config = await load('/api/config'); + if ( + config.oauthEnabled !== true || config.oauthClientId !== clientId || + config.jmapServerUrl !== serverUrl || !httpsUrl(config.oauthIssuerUrl) + ) return false; + const metadata = await load('/api/auth/oauth/metadata'); + return metadata.issuer === config.oauthIssuerUrl && metadata.token_endpoint === tokenEndpoint; + } catch { + return false; + } +} + function buildHandoffUrl(webmailUrl: string, state: string): string { const base = webmailUrl.replace(/\/+$/, ''); const params = new URLSearchParams({ @@ -154,7 +208,7 @@ export async function runWebmailHandoff(webmailUrl: string): Promise Date: Thu, 24 Sep 2026 09:47:15 +0200 Subject: [PATCH 02/12] fix(calendar): join the meeting and open the location from the event sheet Invitations - Teams above all - leave virtualLocations empty: LOCATION reads "Microsoft Teams Meeting" and the join URL sits in the description between help and dial-in links, so the sheet showed no meeting button. Find the join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi, Whereby, GoTo) in the location or description, unwrapping Outlook Safe Links, and offer it as the meeting link. Nothing is written to the event. The location was plain text. Tapping it now opens it: a URL opens as a link, a location that only names the service joins the meeting, anything else is searched in the maps app (geo: intent on Android, Google Maps universal link elsewhere). Long-press copies it. Co-Authored-By: Claude Opus 5.5 --- locales/rn/en.json | 3 + src/components/calendar/EventDetailSheet.tsx | 51 ++++++- src/lib/__tests__/event-links.test.ts | 123 ++++++++++++++++ src/lib/event-links.ts | 140 +++++++++++++++++++ 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/event-links.test.ts create mode 100644 src/lib/event-links.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index a629c21a..0cddce4a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -10,6 +10,9 @@ "days_clear": "The next {count} days are clear.", "no_upcoming": "No upcoming events" }, + "detail": { + "location_hint": "Opens the location; long-press to copy it" + }, "drawer": { "my_calendars": "My calendars", "no_calendars": "No calendars yet.", diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index f5a5a839..ba1faf0b 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -4,14 +4,18 @@ import { Dimensions, Easing, Modal, + Platform, Pressable, ScrollView, StyleSheet, Text, + ToastAndroid, View, } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; import { openExternalUrl } from '../../lib/open-url'; import { splitTextLinks } from '../../lib/linkify-text'; +import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links'; import { SafeAreaView } from 'react-native-safe-area-context'; import { AlignLeft, @@ -194,8 +198,11 @@ export function EventDetailSheet({ const recurrence = recurrenceLabel(event, t, locale); const reminders = alertsToReminders(event.alerts); const participants = getParticipantList(event); - const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; - const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; + const location = primaryLocationName(event); + // Invitations (Teams above all) leave virtualLocations empty and bury the + // join URL in the description; surface it as the meeting link anyway. + const meeting = findMeetingLink(event); + const videoUri = meeting?.uri; const isCancelled = event.status === 'cancelled'; // Can the signed-in user RSVP? Only when they appear as a non-organizer @@ -217,6 +224,25 @@ export function EventDetailSheet({ ); const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined; + const openLocation = () => { + if (!location) return; + const action = locationAction(location, meeting); + if (action.kind === 'url') { + void openExternalUrl(action.uri, { confirm: true }); + } else { + void openExternalUrl(mapsUrl(action.query, Platform.OS)); + } + }; + + const copyLocation = () => { + if (!location) return; + void Clipboard.setStringAsync(location).then(() => { + if (Platform.OS === 'android') { + ToastAndroid.show(t('notifications.copied_to_clipboard', 'Copied to clipboard'), ToastAndroid.SHORT); + } + }).catch(() => {}); + }; + const doRsvp = async (status: RsvpStatus) => { if (!onRsvp || !myParticipantId || rsvpBusy) return; setRsvpBusy(true); @@ -267,7 +293,21 @@ export function EventDetailSheet({ > } text={range} /> {location ? ( - } text={location} /> + + + + + + {location} + + ) : null} {videoUri ? ( @@ -278,7 +318,10 @@ export function EventDetailSheet({ style={styles.joinBtn} onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }} > - {t('calendar.detail.open_link', 'Open link')} + + {t('calendar.detail.open_link', 'Open link')} + {meeting?.provider ? ` · ${meeting.provider}` : ''} + ) : null} diff --git a/src/lib/__tests__/event-links.test.ts b/src/lib/__tests__/event-links.test.ts new file mode 100644 index 00000000..a987bc8f --- /dev/null +++ b/src/lib/__tests__/event-links.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { + findMeetingLink, + isMeetingLabel, + locationAction, + mapsUrl, + meetingProviderOf, + primaryLocationName, + unwrapSafeLink, +} from '../event-links'; + +/** + * A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves + * the join URL in the description, next to help and dial-in links. The sheet + * must still offer a single "Join" button, and a real address must open the + * maps app. + */ + +const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d'; + +const TEAMS_DESCRIPTION = [ + '________________________________________________________________________________', + 'Réunion Microsoft Teams', + 'Rejoindre sur votre ordinateur, application mobile ou appareil de salle', + `Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`, + 'ID de la réunion : 319 560 123 45', + 'Code secret : CTYa9', + 'Télécharger Teams | Rejoindre sur le web', + 'En savoir plus | Options de réunion', +].join('\n'); + +describe('findMeetingLink', () => { + it('prefers the structured virtual location', () => { + expect(findMeetingLink({ + virtualLocations: { v: { uri: 'https://zoom.us/j/123' } }, + description: TEAMS_DESCRIPTION, + })).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false }); + }); + + it('digs the Teams join URL out of the description, skipping help links', () => { + expect(findMeetingLink({ + locations: { l: { name: 'Réunion Microsoft Teams' } }, + description: TEAMS_DESCRIPTION, + })).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true }); + }); + + it('recognises the short Teams, Meet and Zoom forms', () => { + expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams'); + expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij'); + expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom'); + }); + + it('takes a meeting URL used as the location', () => { + expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup'); + }); + + it('unwraps Outlook Safe Links', () => { + const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`; + expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN); + }); + + it('ignores ordinary links', () => { + expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull(); + expect(findMeetingLink({})).toBeNull(); + }); + + it('skips a virtual location that is not a web URL', () => { + expect(findMeetingLink({ + virtualLocations: { v: { uri: 'tel:+33123456789' } }, + description: 'https://meet.google.com/abc-defg-hij', + })?.provider).toBe('Google Meet'); + }); +}); + +describe('meetingProviderOf / unwrapSafeLink', () => { + it('rejects look-alike hosts', () => { + expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull(); + expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull(); + }); + + it('leaves non-wrapped URLs alone', () => { + expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y'); + }); +}); + +describe('locationAction', () => { + const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true }; + + it('searches the maps for a postal address', () => { + expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' }); + }); + + it('joins the meeting when the location only names the service', () => { + expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN }); + expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' }); + }); + + it('opens a URL location', () => { + expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' }); + }); + + it('keeps a numbered room as a place', () => { + expect(isMeetingLabel('Salle Teams 3')).toBe(false); + expect(isMeetingLabel('Zoom')).toBe(true); + }); +}); + +describe('mapsUrl', () => { + it('uses the geo: intent on Android', () => { + expect(mapsUrl('12 rue de la Paix,\n75002 Paris', 'android')).toBe('geo:0,0?q=12%20rue%20de%20la%20Paix%2C%2075002%20Paris'); + }); + + it('uses the Google Maps universal link elsewhere', () => { + expect(mapsUrl('Gare de Lyon', 'ios')).toBe('https://www.google.com/maps/search/?api=1&query=Gare%20de%20Lyon'); + }); +}); + +describe('primaryLocationName', () => { + it('trims and drops blank names', () => { + expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined(); + expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon'); + }); +}); diff --git a/src/lib/event-links.ts b/src/lib/event-links.ts new file mode 100644 index 00000000..b7b94eff --- /dev/null +++ b/src/lib/event-links.ts @@ -0,0 +1,140 @@ +/** + * What an event's place fields should do when tapped. + * + * Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives + * with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in + * the description, among dial-in numbers and help links. `findMeetingLink` + * digs it out so the sheet can offer one "Join" button without touching the + * event on the server (it is someone else's invitation). + * + * A physical address is handed to the platform's maps app instead. + */ + +import type { CalendarEvent } from '../api/types'; + +export interface MeetingLink { + uri: string; + /** Display name of the conferencing service, when recognised. */ + provider?: string; + /** True when the link was found in the description, not a structured field. */ + derived: boolean; +} + +// Hosts whose URLs are joinable meetings. The path test keeps help pages, +// "learn more" and dial-in links on the same host out of the result. +const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ + { name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i }, + { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|my|s)\//i }, + { name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i }, + { name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i }, + { name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ }, + { name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ }, + { name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ }, + { name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ }, +]; + +const URL_RE = /https?:\/\/[^\s<>"']+/g; + +const isHttpUrl = (value: string | undefined | null): value is string => + !!value && /^https?:\/\/\S+$/i.test(value.trim()); + +/** + * Outlook's Safe Links and similar gateways wrap the real URL in a query + * parameter; unwrap one level so the provider can be recognised. + */ +export function unwrapSafeLink(uri: string): string { + try { + const u = new URL(uri); + if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) { + const inner = u.searchParams.get('url'); + if (inner && isHttpUrl(inner)) return inner; + } + } catch { + // not a parseable URL - keep as is + } + return uri; +} + +/** The conferencing service a URL joins, or null for any other link. */ +export function meetingProviderOf(uri: string): string | null { + let u: URL; + try { + u = new URL(unwrapSafeLink(uri)); + } catch { + return null; + } + const host = u.hostname.toLowerCase(); + const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname))); + return provider ? provider.name : null; +} + +function firstMeetingUrl(text: string | undefined | null): MeetingLink | null { + if (!text) return null; + for (const raw of text.match(URL_RE) ?? []) { + // Trailing punctuation belongs to the sentence, not the URL. + const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, '')); + const provider = meetingProviderOf(candidate); + if (provider) return { uri: candidate, provider, derived: true }; + } + return null; +} + +/** + * The link that joins the meeting: the event's own virtual location first, + * then a URL used as the location, then a known conferencing URL found in the + * location or the description. + */ +export function findMeetingLink(event: Pick): MeetingLink | null { + for (const vl of Object.values(event.virtualLocations ?? {})) { + if (isHttpUrl(vl?.uri)) { + return { uri: vl.uri.trim(), provider: meetingProviderOf(vl.uri) ?? undefined, derived: false }; + } + } + const locations = Object.values(event.locations ?? {}); + for (const loc of locations) { + const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description); + if (found) return found; + } + return firstMeetingUrl(event.description); +} + +/** The first location's display name, if any. */ +export function primaryLocationName(event: Pick): string | undefined { + const name = Object.values(event.locations ?? {})[0]?.name?.trim(); + return name || undefined; +} + +// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"… +// A location like this names the service, not a place: tapping it should join +// the meeting, never search the maps for it. +const MEETING_LABEL_RE = /\b(microsoft teams|teams|zoom|google meet|webex|jitsi|whereby|gotomeeting|visio(conf[ée]rence)?|online|en ligne)\b/i; + +export function isMeetingLabel(location: string): boolean { + return MEETING_LABEL_RE.test(location) && !/\d/.test(location); +} + +export type LocationAction = + | { kind: 'url'; uri: string } + | { kind: 'maps'; query: string }; + +/** + * What tapping the location does: open it when it is a URL, join the meeting + * when it only names the service, search the maps otherwise. + */ +export function locationAction(location: string, meeting: MeetingLink | null): LocationAction { + const value = location.trim(); + if (isHttpUrl(value)) return { kind: 'url', uri: value }; + if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri }; + return { kind: 'maps', query: value }; +} + +/** + * A maps URL for a free-text address. Android's `geo:` intent opens Google + * Maps (or the user's chosen maps app) straight on the search; elsewhere the + * Google Maps universal link opens the app when installed, the site otherwise. + */ +export function mapsUrl(query: string, platform: string): string { + const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim()); + if (platform === 'android') return `geo:0,0?q=${q}`; + return `https://www.google.com/maps/search/?api=1&query=${q}`; +} From bd1b2f3fd3d7e7933330faacc04f680bf3b61d19 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:59:58 +0200 Subject: [PATCH 03/12] fix(calendar): keep app-scheme meeting links and match service labels exactly A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last resort instead of hiding the meeting row; script schemes never are. A location only joins the meeting when the whole label is a service name, so "Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path. Co-Authored-By: Claude Opus 5.5 --- src/lib/__tests__/event-links.test.ts | 21 ++++++++++-- src/lib/event-links.ts | 49 +++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/lib/__tests__/event-links.test.ts b/src/lib/__tests__/event-links.test.ts index a987bc8f..72c78673 100644 --- a/src/lib/__tests__/event-links.test.ts +++ b/src/lib/__tests__/event-links.test.ts @@ -64,6 +64,17 @@ describe('findMeetingLink', () => { expect(findMeetingLink({})).toBeNull(); }); + it('falls back to an app-scheme virtual location, never a script one', () => { + expect(findMeetingLink({ virtualLocations: { v: { uri: 'msteams:/l/meetup-join/19%3a1' } } })) + .toEqual({ uri: 'msteams:/l/meetup-join/19%3a1', derived: false }); + expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull(); + expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull(); + }); + + it('recognises the Zoom web-client join path', () => { + expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom'); + }); + it('skips a virtual location that is not a web URL', () => { expect(findMeetingLink({ virtualLocations: { v: { uri: 'tel:+33123456789' } }, @@ -99,9 +110,13 @@ describe('locationAction', () => { expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' }); }); - it('keeps a numbered room as a place', () => { - expect(isMeetingLabel('Salle Teams 3')).toBe(false); - expect(isMeetingLabel('Zoom')).toBe(true); + it('only treats a bare service name as a meeting', () => { + for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) { + expect(isMeetingLabel(label)).toBe(true); + } + for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) { + expect(isMeetingLabel(label)).toBe(false); + } }); }); diff --git a/src/lib/event-links.ts b/src/lib/event-links.ts index b7b94eff..1bc03ad6 100644 --- a/src/lib/event-links.ts +++ b/src/lib/event-links.ts @@ -10,7 +10,12 @@ * A physical address is handed to the platform's maps app instead. */ -import type { CalendarEvent } from '../api/types'; +/** The place fields read here, loose enough for any event shape. */ +export interface EventPlaces { + locations?: Record | null; + virtualLocations?: Record | null; + description?: string | null; +} export interface MeetingLink { uri: string; @@ -24,7 +29,7 @@ export interface MeetingLink { // "learn more" and dial-in links on the same host out of the result. const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i }, - { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|my|s)\//i }, + { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i }, { name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i }, { name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i }, { name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ }, @@ -33,6 +38,8 @@ const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ }, ]; +const UNSAFE_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'blob', 'about']); + const URL_RE = /https?:\/\/[^\s<>"']+/g; const isHttpUrl = (value: string | undefined | null): value is string => @@ -84,10 +91,11 @@ function firstMeetingUrl(text: string | undefined | null): MeetingLink | null { * then a URL used as the location, then a known conferencing URL found in the * location or the description. */ -export function findMeetingLink(event: Pick): MeetingLink | null { +export function findMeetingLink(event: EventPlaces): MeetingLink | null { for (const vl of Object.values(event.virtualLocations ?? {})) { - if (isHttpUrl(vl?.uri)) { - return { uri: vl.uri.trim(), provider: meetingProviderOf(vl.uri) ?? undefined, derived: false }; + const uri = vl?.uri; + if (isHttpUrl(uri)) { + return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false }; } } const locations = Object.values(event.locations ?? {}); @@ -95,22 +103,43 @@ export function findMeetingLink(event: Pick): string | undefined { +export function primaryLocationName(event: Pick): string | undefined { const name = Object.values(event.locations ?? {})[0]?.name?.trim(); return name || undefined; } // "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"… // A location like this names the service, not a place: tapping it should join -// the meeting, never search the maps for it. -const MEETING_LABEL_RE = /\b(microsoft teams|teams|zoom|google meet|webex|jitsi|whereby|gotomeeting|visio(conf[ée]rence)?|online|en ligne)\b/i; +// the meeting, never search the maps for it. The whole label must be a service +// name, so "Salle Teams" or "Studio Zoom" stay places. +const MEETING_LABELS = new Set([ + 'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby', + 'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne', +]); export function isMeetingLabel(location: string): boolean { - return MEETING_LABEL_RE.test(location) && !/\d/.test(location); + const label = location + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z ]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/^(reunion|meeting|online meeting|appel|call) /, '') + .replace(/ (meeting|reunion|call)$/, ''); + return MEETING_LABELS.has(label); } export type LocationAction = From 89f0524ad443a033b4bd66143b1441457268506a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:30:57 +0200 Subject: [PATCH 04/12] fix: only offer meeting links the app can open #59 --- src/lib/__tests__/event-links.test.ts | 19 ++++++++++++++++--- src/lib/event-links.ts | 13 +++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/lib/__tests__/event-links.test.ts b/src/lib/__tests__/event-links.test.ts index 72c78673..9e2c4bf7 100644 --- a/src/lib/__tests__/event-links.test.ts +++ b/src/lib/__tests__/event-links.test.ts @@ -8,6 +8,7 @@ import { primaryLocationName, unwrapSafeLink, } from '../event-links'; +import { isSafeExternalUrl } from '../open-url'; /** * A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves @@ -64,13 +65,25 @@ describe('findMeetingLink', () => { expect(findMeetingLink({})).toBeNull(); }); - it('falls back to an app-scheme virtual location, never a script one', () => { - expect(findMeetingLink({ virtualLocations: { v: { uri: 'msteams:/l/meetup-join/19%3a1' } } })) - .toEqual({ uri: 'msteams:/l/meetup-join/19%3a1', derived: false }); + it('falls back to an openable non-web virtual location, never a script one', () => { + expect(findMeetingLink({ virtualLocations: { v: { uri: 'tel:+33123456789' } } })) + .toEqual({ uri: 'tel:+33123456789', derived: false }); expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull(); expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull(); }); + // openExternalUrl refuses these schemes, so offering one gave a Join button + // and a location tap that did nothing. + it('never offers an app-scheme link the app cannot open', () => { + for (const uri of ['msteams:/l/meetup-join/19%3a1', 'zoommtg://zoom.us/join?confno=1', 'sip:room@example.com']) { + expect(findMeetingLink({ virtualLocations: { v: { uri } } })).toBeNull(); + expect(isSafeExternalUrl(uri)).toBe(false); + } + expect(findMeetingLink({ + virtualLocations: { a: { uri: 'msteams:/l/meetup-join/19%3a1' }, b: { uri: 'tel:+33123456789' } }, + })?.uri).toBe('tel:+33123456789'); + }); + it('recognises the Zoom web-client join path', () => { expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom'); }); diff --git a/src/lib/event-links.ts b/src/lib/event-links.ts index 1bc03ad6..53f4afc9 100644 --- a/src/lib/event-links.ts +++ b/src/lib/event-links.ts @@ -10,6 +10,8 @@ * A physical address is handed to the platform's maps app instead. */ +import { isSafeExternalUrl } from './open-url'; + /** The place fields read here, loose enough for any event shape. */ export interface EventPlaces { locations?: Record | null; @@ -38,8 +40,6 @@ const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ }, ]; -const UNSAFE_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'blob', 'about']); - const URL_RE = /https?:\/\/[^\s<>"']+/g; const isHttpUrl = (value: string | undefined | null): value is string => @@ -105,12 +105,13 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null { } const inDescription = firstMeetingUrl(event.description); if (inDescription) return inDescription; - // Last resort: a virtual location in an app scheme (msteams:, zoommtg:, - // sip:…) still beats no link at all. Never a scheme that runs code. + // Last resort: a virtual location in another scheme the app may open (a + // tel: dial-in, say). App schemes like msteams:, zoommtg: or sip: are left + // out because openExternalUrl refuses them, and a Join button that does + // nothing is worse than none. for (const vl of Object.values(event.virtualLocations ?? {})) { const uri = vl?.uri?.trim(); - const scheme = uri ? /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1].toLowerCase() : undefined; - if (uri && scheme && !UNSAFE_SCHEMES.has(scheme)) return { uri, derived: false }; + if (isSafeExternalUrl(uri)) return { uri, derived: false }; } return null; } From 28f6787279552f4d9884232dc984c14d15a84e38 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:32:04 +0200 Subject: [PATCH 05/12] fix: confirm a copied event location on iOS too #59 --- src/components/calendar/EventDetailSheet.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index fdf164f9..eaa7ef07 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -9,11 +9,12 @@ import { ScrollView, StyleSheet, Text, - ToastAndroid, View, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { openExternalUrl } from '../../lib/open-url'; +import { toast } from '../../stores/toast-store'; +import { ToastHost } from '../ToastHost'; import { splitTextLinks } from '../../lib/linkify-text'; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -227,9 +228,7 @@ export function EventDetailSheet({ const copyLocation = () => { if (!location) return; void Clipboard.setStringAsync(location).then(() => { - if (Platform.OS === 'android') { - ToastAndroid.show(t('notifications.copied_to_clipboard', 'Copied to clipboard'), ToastAndroid.SHORT); - } + toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard')); }).catch(() => {}); }; @@ -457,6 +456,9 @@ export function EventDetailSheet({ )} + {/* The screen's own toast host sits under this Modal; this one shows + the copy confirmation above the sheet. */} + ); } From 200404483c4cb0e0320ec589d27b00134cd92c6b Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:01:28 +0200 Subject: [PATCH 06/12] fix(notifications): ensure notification tap always opens target email --- App.tsx | 1 + .../java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt | 9 ++++++++- src/lib/__tests__/viewer-pages.test.ts | 8 ++++++++ src/screens/EmailThreadScreen.tsx | 7 ++++++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/App.tsx b/App.tsx index 5e152ebf..4622f730 100644 --- a/App.tsx +++ b/App.tsx @@ -119,6 +119,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, + emailIds: [payload.emailId], subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 6922adbe..c69d042c 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -4,6 +4,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.net.Uri import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -114,6 +115,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-notification://$notificationId") if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) @@ -157,7 +159,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) it.groupKey?.endsWith(groupKey) == true && (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0 } - val count = maxOf(children.size, 1) + if (children.size < 2) { + manager.cancel(groupKey, groupKey.hashCode()) + return + } + val count = children.size val inbox = NotificationCompat.InboxStyle() children.sortedByDescending { it.postTime }.take(5).forEach { sbn -> val extras = sbn.notification.extras @@ -172,6 +178,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-group-summary://$groupKey") if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) } val pending = PendingIntent.getActivity( diff --git a/src/lib/__tests__/viewer-pages.test.ts b/src/lib/__tests__/viewer-pages.test.ts index bad52442..1120ad41 100644 --- a/src/lib/__tests__/viewer-pages.test.ts +++ b/src/lib/__tests__/viewer-pages.test.ts @@ -41,6 +41,14 @@ describe('viewerPages', () => { expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]); }); + it('is a single page when opened with a single emailId from notification tap', () => { + const pages = viewerPages({ + emailId: 'q2', threadId: 'tq', emailIds: ['q2'], + list: LIST, threading: true, + }); + expect(pages).toEqual([LIST[1]]); + }); + it('is a single page for a message the list does not hold', () => { const pages = viewerPages({ emailId: 'm9', threadId: 't9', list: LIST, threading: true, diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index aad8f3f9..851d965b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -735,12 +735,17 @@ function EmailViewer({ route, navigation }: Props) { initialScrollIndex={initialIndexRef.current} getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })} windowSize={3} - initialNumToRender={1} + initialNumToRender={Math.max(initialIndexRef.current + 1, 1)} maxToRenderPerBatch={2} removeClippedSubviews scrollEnabled={!pagerLocked} onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} + onScrollToIndexFailed={(info) => { + setTimeout(() => { + listRef.current?.scrollToOffset({ offset: info.offset, animated: false }); + }, 50); + }} renderItem={({ item, index }) => ( Date: Fri, 9 Oct 2026 09:05:27 +0200 Subject: [PATCH 07/12] fix(viewer): use info.index * windowWidth for onScrollToIndexFailed offset --- src/screens/EmailThreadScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 851d965b..03aa7996 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -743,7 +743,7 @@ function EmailViewer({ route, navigation }: Props) { onMomentumScrollEnd={onMomentumEnd} onScrollToIndexFailed={(info) => { setTimeout(() => { - listRef.current?.scrollToOffset({ offset: info.offset, animated: false }); + listRef.current?.scrollToOffset({ offset: info.index * windowWidth, animated: false }); }, 50); }} renderItem={({ item, index }) => ( From 90fe43882a6ecd73912f90d5fd49e0ca97b63952 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:16:53 +0200 Subject: [PATCH 08/12] feat(notifications): add email preview snippet and quick actions (mark read, delete, reply) --- App.tsx | 1 + android/app/src/main/AndroidManifest.xml | 2 + .../bulwarkmobile/BulwarkFcmModule.kt | 98 ++++++++++++++++++- .../BulwarkNotificationActionReceiver.kt | 34 +++++++ .../BulwarkNotificationActionService.kt | 18 ++++ .../bulwarkmobile/NotificationTapStore.kt | 7 +- index.ts | 6 ++ .../__tests__/push-background-task.test.ts | 67 +++++++++++++ src/lib/push-background-task.ts | 84 ++++++++++++++++ src/lib/push-notifications.ts | 2 + 10 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt diff --git a/App.tsx b/App.tsx index 4622f730..a275df46 100644 --- a/App.tsx +++ b/App.tsx @@ -123,6 +123,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), + ...(payload.action === 'reply' ? { action: 'reply' as const } : {}), }); } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8106099a..68ab6fab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -76,6 +76,8 @@ + + diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index c69d042c..105c4442 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -73,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle") ?: accountId ?: "Bulwark Mail" + val preview = options.takeIf { it.hasKey("preview") }?.getString("preview") + val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read" + val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete" + val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply" + // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. thread(name = "bulwark-notification") { val largeIcon = iconUrl?.let { fetchBitmap(it) } ?: makeLetterAvatar(initials, bgColorHex) postNotification( - notificationId, title, body, largeIcon, bgColorHex, + notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, + groupTitle, markReadLabel, deleteLabel, replyLabel, ) if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) @@ -103,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) notificationId: String, title: String, body: String, + preview: String?, largeIcon: Bitmap, colorHex: String, emailId: String?, @@ -111,6 +118,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) accountId: String?, jmapAccountId: String?, groupKey: String?, + groupTitle: String?, + markReadLabel: String, + deleteLabel: String, + replyLabel: String, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { @@ -129,12 +140,25 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val bigText = buildString { + if (!subject.isNullOrBlank()) append(subject) + if (!preview.isNullOrBlank()) { + if (isNotEmpty()) append("\n") + append(preview) + } + } + val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setContentTitle(title) - .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentText(subject ?: preview ?: body) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(if (bigText.isNotBlank()) bigText else body) + .setBigContentTitle(title) + .setSummaryText(groupTitle) + ) .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -144,6 +168,69 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) } + if (emailId != null && accountId != null) { + // Action 1: Mark as read + val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_MARK_READ + data = Uri.parse("bulwark-action-read://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "markRead") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val markReadPending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":read").hashCode(), + markReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build() + ) + + // Action 2: Delete + val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_DELETE + data = Uri.parse("bulwark-action-delete://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "delete") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val deletePending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":delete").hashCode(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, deleteLabel, deletePending).build() + ) + + // Action 3: Reply + val replyIntent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-reply://$notificationId") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) + if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + putExtra(EXTRA_ACTION, "reply") + } + val replyPending = PendingIntent.getActivity( + ctx, + (notificationId + ":reply").hashCode(), + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, replyLabel, replyPending).build() + ) + } + val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(notificationId, notificationId.hashCode(), builder.build()) } @@ -280,6 +367,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) @Volatile private var currentInstance: BulwarkFcmModule? = null + const val EXTRA_ACTION = "bulwark.notification.action" + const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId" + const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ" + const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE" + fun emit(eventName: String, params: WritableMap?) { val module = currentInstance ?: return val ctx = module.reactApplicationContext diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt new file mode 100644 index 00000000..94bdca1c --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -0,0 +1,34 @@ +package com.anonymous.bulwarkmobile + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class BulwarkNotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID) + val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION) + val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID) + + // Dismiss the notification immediately from the tray + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationId != null) { + manager.cancel(notificationId, notificationId.hashCode()) + } + + if (emailId.isNullOrBlank() || action.isNullOrBlank()) return + + // Dispatch background headless task to perform JMAP operations + val serviceIntent = Intent(context, BulwarkNotificationActionService::class.java).apply { + intent.extras?.let { putExtras(it) } + } + + try { + context.startService(serviceIntent) + } catch (e: Exception) { + Log.w("BulwarkNotifAction", "Failed to start action service: ${e.message}") + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt new file mode 100644 index 00000000..644e9a98 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt @@ -0,0 +1,18 @@ +package com.anonymous.bulwarkmobile + +import android.content.Intent +import com.facebook.react.HeadlessJsTaskService +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig + +class BulwarkNotificationActionService : HeadlessJsTaskService() { + override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? { + val extras = intent?.extras ?: return null + return HeadlessJsTaskConfig( + "BulwarkNotificationAction", + Arguments.fromBundle(extras), + 15000L, + true // allowExecutionInForeground + ) + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt index baed3c73..1ebebf61 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt @@ -43,7 +43,8 @@ object NotificationTapStore { // The JMAP account a group/shared mailbox's message lives in - a JMAP // id like the email id. Optional: older notifications don't carry it. val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) } - val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId) + val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" } + val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action) pending = payload // Clear so a subsequent activity lifecycle event doesn't replay this. extras.remove(EXTRA_EMAIL_ID) @@ -51,6 +52,7 @@ object NotificationTapStore { extras.remove(EXTRA_SUBJECT) extras.remove(EXTRA_ACCOUNT_ID) extras.remove(EXTRA_JMAP_ACCOUNT_ID) + extras.remove(EXTRA_ACTION) return payload } @@ -60,6 +62,7 @@ object NotificationTapStore { val subject: String?, val accountId: String?, val jmapAccountId: String?, + val action: String? = null, ) { fun toMap(): WritableMap = Arguments.createMap().apply { putString("emailId", emailId) @@ -67,6 +70,7 @@ object NotificationTapStore { if (subject != null) putString("subject", subject) if (accountId != null) putString("accountId", accountId) if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId) + if (action != null) putString("action", action) } } @@ -75,4 +79,5 @@ object NotificationTapStore { const val EXTRA_SUBJECT = "bulwark.notification.subject" const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId" const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId" + const val EXTRA_ACTION = "bulwark.notification.action" } diff --git a/index.ts b/index.ts index 7db96696..965b95a4 100644 --- a/index.ts +++ b/index.ts @@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]); }); +// Notification quick actions (Mark as read, Delete): run without launching the app UI. +AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => { + const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task'); + await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]); +}); + // Device sync (Android, #34): the contacts and calendar sync adapters run the // sync engine through this task, headless or next to the UI. The engine is // required on first use so an app start does not load it. diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index b8298866..c8137914 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -16,6 +16,7 @@ import { jmapClient } from '../../api/jmap-client'; import { secureFetch } from '../client-cert'; import { carriesNoMail, + handleNotificationAction, matchAccountsForPush, parseRelayPushData, pushBackgroundTask, @@ -185,6 +186,72 @@ describe('pushBackgroundTask notifications', () => { expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' })); }); + + it('includes preview and action labels on notification', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => ({ + ok: true, + json: async () => (url.endsWith('/.well-known/jmap') + ? { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + } + : { + methodResponses: [[ + 'Email/get', + { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hello', preview: 'World snippet', from: [{ name: 'Bob', email: 'bob@example.com' }] }] }, + '0', + ]], + }), + })); + + await pushBackgroundTask({ + kind: 'jmap-email-push', + accountLabel: 'alice', + accountId: 'jmap-primary', + emailIds: JSON.stringify(['m1']), + }); + + expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ + emailId: 'm1', + title: 'Bob', + body: 'Hello', + preview: 'World snippet', + markReadLabel: expect.any(String), + deleteLabel: expect.any(String), + replyLabel: expect.any(String), + })); + }); + + it('handles markRead action via detached JMAP', async () => { + const postCalls: any[] = []; + (secureFetch as ReturnType).mockImplementation(async (url: string, opts?: any) => { + if (opts?.body) { + postCalls.push(JSON.parse(opts.body)); + } + return { + ok: true, + json: async () => (url.endsWith('/.well-known/jmap') + ? { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + } + : { methodResponses: [['Email/set', { updated: { m1: {} } }, '0']] }), + }; + }); + + await handleNotificationAction({ + action: 'markRead', + emailId: 'm1', + accountId: LOCAL, + }); + + expect(postCalls.length).toBe(1); + expect(postCalls[0].methodCalls).toEqual([ + ['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'], + ]); + }); }); describe('pushes for device sync (#34)', () => { diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 1f513bc9..537b5058 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -12,6 +12,7 @@ import { getFaviconDomain, getFaviconUrl, } from './avatar-utils'; +import { singleLine } from './single-line'; import { lastNotifiedKey, migrateLegacyPushKeys, @@ -87,6 +88,7 @@ interface ShowNotificationOptions { notificationId: string; title: string; body: string; + preview?: string; initials: string; bgColorHex: string; iconUrl?: string; @@ -101,6 +103,9 @@ interface ShowNotificationOptions { // several deliveries under a "+N more" summary instead of stacking them. groupKey: string; groupTitle: string; + markReadLabel?: string; + deleteLabel?: string; + replyLabel?: string; } interface BulwarkFcmNative { @@ -505,10 +510,16 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): const faviconDomain = favicons ? getFaviconDomain(address) : null; const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; + const preview = email.preview ? singleLine(email.preview) : undefined; + const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read'); + const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); + const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); + await native.showNotification({ notificationId: `mail:${email.id}`, title, body, + preview, initials, bgColorHex, iconUrl, @@ -519,6 +530,9 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): jmapAccountId: emailAccountId, groupKey, groupTitle, + markReadLabel, + deleteLabel, + replyLabel, }); } @@ -539,3 +553,73 @@ function hslToHex(hsl: string): string { }; return `#${component(0)}${component(8)}${component(4)}`; } + +export interface NotificationActionPayload { + action: 'markRead' | 'delete'; + emailId: string; + accountId: string; + jmapAccountId?: string; +} + +export async function handleNotificationAction(data: unknown): Promise { + if (!data || typeof data !== 'object') return; + const p = data as NotificationActionPayload; + const { action, emailId, accountId } = p; + if (!action || !emailId || !accountId) return; + + try { + const session = await openDetachedSession(accountId); + if (!session) return; + const emailAccountId = p.jmapAccountId ?? session.jmapAccountId; + + if (action === 'markRead') { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { 'keywords/$seen': true }, + }, + }, + '0', + ], + ]); + } else if (action === 'delete') { + const mbRes = await jmapPost(session, [ + ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], + ]); + const [, mbBody] = mbRes[0] ?? []; + const mailboxes = ((mbBody?.list as Mailbox[]) ?? []); + const trash = mailboxes.find((m) => m.role === 'trash'); + + if (trash) { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { mailboxIds: { [trash.id]: true } }, + }, + }, + '0', + ], + ]); + } else { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + destroy: [emailId], + }, + '0', + ], + ]); + } + } + } catch (err) { + console.warn('[push] notification action failed', action, emailId, err); + } +} diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 97c48971..5ea47bd0 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1443,6 +1443,8 @@ export interface NotificationTapPayload { // The JMAP account the message lives in: a group or shared mailbox's account // when it isn't the user's own. Absent on older notifications. jmapAccountId?: string; + // Notification action tapped (e.g. 'reply' to jump straight to Compose) + action?: 'reply'; } /** From be15af5d4c6878365cdc57c4e2c8c1cbf206528d Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:41:53 +0200 Subject: [PATCH 09/12] fix(notifications): execute background actions directly via HeadlessJs without service --- android/app/src/main/AndroidManifest.xml | 1 - .../BulwarkNotificationActionReceiver.kt | 104 ++++++++++++++++-- .../BulwarkNotificationActionService.kt | 18 --- .../__tests__/push-background-task.test.ts | 35 ++++++ src/lib/push-background-task.ts | 32 +++++- 5 files changed, 158 insertions(+), 32 deletions(-) delete mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 68ab6fab..5485a398 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -77,7 +77,6 @@ - diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt index 94bdca1c..ae210a4e 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -1,16 +1,26 @@ package com.anonymous.bulwarkmobile +import android.app.Application import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener class BulwarkNotificationActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID) val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION) val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID) + val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID) + val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID) // Dismiss the notification immediately from the tray val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -18,17 +28,97 @@ class BulwarkNotificationActionReceiver : BroadcastReceiver() { manager.cancel(notificationId, notificationId.hashCode()) } - if (emailId.isNullOrBlank() || action.isNullOrBlank()) return - - // Dispatch background headless task to perform JMAP operations - val serviceIntent = Intent(context, BulwarkNotificationActionService::class.java).apply { - intent.extras?.let { putExtras(it) } + if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) { + Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId") + return } + // On Android 8+, a broadcast receiver in background cannot start a Service + // (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and + // HeadlessJs.withReadyReactContext to execute the headless JS task directly. + val pendingResult = goAsync() + val app = context.applicationContext as Application + val mainHandler = Handler(Looper.getMainLooper()) + + // Safety timeout to ensure pendingResult.finish() is always called + val timeoutRunnable = Runnable { + Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)") + try { + pendingResult.finish() + } catch (_: Exception) {} + } + mainHandler.postDelayed(timeoutRunnable, 20_000L) + try { - context.startService(serviceIntent) + HeadlessJs.withReadyReactContext(app) { reactContext -> + if (!reactContext.hasActiveReactInstance()) { + Log.w(TAG, "React context does not have active instance") + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + return@withReadyReactContext + } + + val tasks = HeadlessJsTaskContext.getInstance(reactContext) + val taskBundle = Bundle().apply { + putString("action", action) + putString("emailId", emailId) + putString("accountId", accountId) + if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId) + // Keep raw extras as well + putString(BulwarkFcmModule.EXTRA_ACTION, action) + putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + + val config = HeadlessJsTaskConfig( + "BulwarkNotificationAction", + Arguments.fromBundle(taskBundle), + 15_000L, + true // allowExecutionInForeground + ) + + val listener = object : HeadlessJsTaskEventListener { + override fun onHeadlessJsTaskStart(taskId: Int) = Unit + override fun onHeadlessJsTaskFinish(taskId: Int) { + Log.i(TAG, "Notification action task $taskId finished for action $action") + tasks.removeTaskEventListener(this) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + } + } + tasks.addTaskEventListener(listener) + + HeadlessJs.startTask( + tasks, + config, + onStarted = { taskId -> + Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)") + }, + onRefused = { e -> + Log.w(TAG, "Notification action task refused: ${e.message}") + tasks.removeTaskEventListener(listener) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + } + ) + } } catch (e: Exception) { - Log.w("BulwarkNotifAction", "Failed to start action service: ${e.message}") + Log.e(TAG, "Failed to dispatch headless task from action receiver", e) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} } } + + companion object { + private const val TAG = "BulwarkNotifAction" + } } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt deleted file mode 100644 index 644e9a98..00000000 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.anonymous.bulwarkmobile - -import android.content.Intent -import com.facebook.react.HeadlessJsTaskService -import com.facebook.react.bridge.Arguments -import com.facebook.react.jstasks.HeadlessJsTaskConfig - -class BulwarkNotificationActionService : HeadlessJsTaskService() { - override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? { - val extras = intent?.extras ?: return null - return HeadlessJsTaskConfig( - "BulwarkNotificationAction", - Arguments.fromBundle(extras), - 15000L, - true // allowExecutionInForeground - ) - } -} diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index c8137914..dadfd7b8 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -252,6 +252,41 @@ describe('pushBackgroundTask notifications', () => { ['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'], ]); }); + + it('handles delete action by moving email to trash', async () => { + const postCalls: any[] = []; + (secureFetch as ReturnType).mockImplementation(async (url: string, opts?: any) => { + if (opts?.body) { + postCalls.push(JSON.parse(opts.body)); + } + return { + ok: true, + json: async () => (url.endsWith('/.well-known/jmap') + ? { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + } + : { + methodResponses: [ + ['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'], + ['Email/set', { updated: { m1: {} } }, '0'], + ], + }), + }; + }); + + await handleNotificationAction({ + 'bulwark.notification.action': 'delete', + 'bulwark.notification.emailId': 'm1', + 'bulwark.notification.accountId': LOCAL, + }); + + expect(postCalls.length).toBe(2); + expect(postCalls[1].methodCalls).toEqual([ + ['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'], + ]); + }); }); describe('pushes for device sync (#34)', () => { diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 537b5058..56aa87ec 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -561,16 +561,36 @@ export interface NotificationActionPayload { jmapAccountId?: string; } +export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined; + const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined; + const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined; + const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined; + + if ((action === 'markRead' || action === 'delete') && emailId && accountId) { + return { action, emailId, accountId, jmapAccountId }; + } + return null; +} + export async function handleNotificationAction(data: unknown): Promise { - if (!data || typeof data !== 'object') return; - const p = data as NotificationActionPayload; - const { action, emailId, accountId } = p; - if (!action || !emailId || !accountId) return; + const payload = parseNotificationActionPayload(data); + if (!payload) { + console.warn('[push] invalid notification action payload', data); + return; + } + const { action, emailId, accountId, jmapAccountId } = payload; + console.log(`[push] executing notification action "${action}" for email ${emailId}`); try { const session = await openDetachedSession(accountId); - if (!session) return; - const emailAccountId = p.jmapAccountId ?? session.jmapAccountId; + if (!session) { + console.warn('[push] failed to open detached session for account', accountId); + return; + } + const emailAccountId = jmapAccountId ?? session.jmapAccountId; if (action === 'markRead') { await jmapPost(session, [ From 5237d9004d19a01e6ea76e28cfa7688bffd72bd8 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:00:51 +0200 Subject: [PATCH 10/12] fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears --- src/components/email/QuickReplyBox.tsx | 6 +- src/screens/EmailThreadScreen.tsx | 132 +++++++++++++++++++------ 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 920a2a99..55fa029a 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -29,6 +29,8 @@ interface Props { onMoreOptions: (draft: string) => void; /** Reflect `$answered` in the caller's cache. */ onSent?: (email: Email) => void; + onFocus?: () => void; + onBlur?: () => void; } /** @@ -36,7 +38,7 @@ interface Props { * to the sender with the original quoted, sent through the identity that * received the message. "More options" hands the text to the full composer. */ -export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) { +export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -147,6 +149,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P { + const recompute = (endY?: number, height?: number) => { + const screenH = Dimensions.get('screen').height; + if (typeof endY === 'number' && screenH - endY > 0) { + setKbObstruction(screenH - endY); + } else if (typeof height === 'number' && height > 0) { + setKbObstruction(height); + } else { + setKbObstruction(0); + } + }; + const subs = + Platform.OS === 'ios' + ? [ + Keyboard.addListener('keyboardWillChangeFrame', (e) => { + recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); + }), + Keyboard.addListener('keyboardWillHide', () => setKbObstruction(0)), + ] + : [ + Keyboard.addListener('keyboardDidShow', (e) => { + recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); + }), + Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)), + ]; + return () => { + for (const s of subs) s.remove(); + }; + }, []); + // Drop optional toolbar buttons on narrow screens. const showMarkUnread = windowWidth >= 340; const showArchive = windowWidth >= 400 && canArchive; @@ -738,7 +775,7 @@ function EmailViewer({ route, navigation }: Props) { initialNumToRender={1} maxToRenderPerBatch={2} removeClippedSubviews - scrollEnabled={!pagerLocked} + scrollEnabled={!pagerLocked && kbObstruction === 0} onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} renderItem={({ item, index }) => ( @@ -769,6 +806,7 @@ function EmailViewer({ route, navigation }: Props) { scheduleMarkRead={scheduleMarkRead} styles={styles} bottomBarHeight={bottomBarHeight} + kbObstruction={item.id === activeEmailId ? kbObstruction : 0} onToggleStar={toggleStarFor} onAddressPress={setAddressSheet} onEmailPatched={onEmailPatched} @@ -780,33 +818,35 @@ function EmailViewer({ route, navigation }: Props) { )} /> - {/* Bottom action bar */} - - } - label={t('email_viewer.previous', 'Prev')} - onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined} - disabled={!prevEmail} - /> - {bottomActions.map((id) => { - const def = quickActionRegistry[id]; - return ( - - ); - })} - } - label={t('email_viewer.next', 'Next')} - onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined} - disabled={!nextEmail} - /> - + {/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */} + {kbObstruction === 0 && ( + + } + label={t('email_viewer.previous', 'Prev')} + onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined} + disabled={!prevEmail} + /> + {bottomActions.map((id) => { + const def = quickActionRegistry[id]; + return ( + + ); + })} + } + label={t('email_viewer.next', 'Next')} + onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined} + disabled={!nextEmail} + /> + + )} )} @@ -917,6 +957,7 @@ interface EmailPaneProps { scheduleMarkRead: (email: Email) => () => void; styles: ReturnType; bottomBarHeight: number; + kbObstruction: number; onToggleStar: (email: Email) => void; onAddressPress: (address: EmailAddress) => void; onEmailPatched: (email: Email) => void; @@ -933,7 +974,7 @@ interface EmailPaneProps { function EmailPane({ id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint, threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails, - ensureThread, scheduleMarkRead, styles, bottomBarHeight, onToggleStar, onAddressPress, + ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange, }: EmailPaneProps) { const c = useColors(); @@ -1028,13 +1069,42 @@ function EmailPane({ onZoomChange(z); }; + const scrollRef = React.useRef(null); + const isInputFocused = React.useRef(false); + + const bottomPadding = kbObstruction > 0 + ? kbObstruction + spacing.md + : bottomBarHeight + spacing.lg; + + const handleQuickReplyFocus = () => { + isInputFocused.current = true; + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 100); + }; + + const handleQuickReplyBlur = () => { + isInputFocused.current = false; + }; + + React.useEffect(() => { + if (kbObstruction > 0 && isInputFocused.current) { + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 50); + } + }, [kbObstruction]); + return ( {/* Subject block */} @@ -1112,6 +1182,8 @@ function EmailPane({ jmapAccountId={jmapAccountId} onMoreOptions={() => onReply('reply', newest)} onSent={onEmailPatched} + onFocus={handleQuickReplyFocus} + onBlur={handleQuickReplyBlur} /> )} From 96e1cc1c3f90e5698938bdb536fac556f302a1bc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:20:46 +0200 Subject: [PATCH 11/12] fix: open a list mail without mounting every pane before it #67 The tap fix makes a notification open a single page, so the viewer no longer needs to render up to the opened index. That setting mounted about N mail panes on every list open, on iOS too. Also drop the onScrollToIndexFailed handler, which FlatList never calls when getItemLayout is set. --- src/screens/EmailThreadScreen.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 84e1cfb1..cb1ed143 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -772,17 +772,12 @@ function EmailViewer({ route, navigation }: Props) { initialScrollIndex={initialIndexRef.current} getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })} windowSize={3} - initialNumToRender={Math.max(initialIndexRef.current + 1, 1)} + initialNumToRender={1} maxToRenderPerBatch={2} removeClippedSubviews scrollEnabled={!pagerLocked && kbObstruction === 0} onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} - onScrollToIndexFailed={(info) => { - setTimeout(() => { - listRef.current?.scrollToOffset({ offset: info.index * windowWidth, animated: false }); - }, 50); - }} renderItem={({ item, index }) => ( Date: Sat, 10 Oct 2026 19:20:53 +0200 Subject: [PATCH 12/12] fix: never destroy mail from the notification Delete action #68 With no Trash mailbox, or when Mailbox/get came back as an error, the Delete action fell through to destroying the email. A shared account without its own Trash lost mail permanently on one tap. - only move to Trash; do nothing when there is no Trash or the lookup failed - check that Email/set actually updated the email - show "(No Subject)" in the collapsed text instead of a blank line - require unlock for Delete (Android 12+) --- .../bulwarkmobile/BulwarkFcmModule.kt | 9 ++- .../__tests__/push-background-task.test.ts | 26 +++++++++ src/lib/push-background-task.ts | 57 ++++++++++--------- 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 105c4442..2bcc8ff7 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -152,7 +152,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setContentTitle(title) - .setContentText(subject ?: preview ?: body) + // body is the subject with the "(No subject)" fallback; an empty + // subject is "" here, not null, so `subject ?: ...` showed a blank line. + .setContentText(body) .setStyle( NotificationCompat.BigTextStyle() .bigText(if (bigText.isNotBlank()) bigText else body) @@ -206,7 +208,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) builder.addAction( - NotificationCompat.Action.Builder(0, deleteLabel, deletePending).build() + NotificationCompat.Action.Builder(0, deleteLabel, deletePending) + // Android 12+: unlock first, so a locked phone can't delete mail. + .setAuthenticationRequired(true) + .build() ) // Action 3: Reply diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index dadfd7b8..676ff2e7 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -287,6 +287,32 @@ describe('pushBackgroundTask notifications', () => { ['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'], ]); }); + + it.each([ + ['has no trash mailbox', ['Mailbox/get', { list: [{ id: 'inbox', role: 'inbox' }] }, '0']], + ['cannot list mailboxes', ['error', { type: 'accountNotFound' }, '0']], + ])('never destroys from a notification when the account %s', async (_label, mailboxResponse) => { + const postCalls: any[] = []; + (secureFetch as ReturnType).mockImplementation(async (url: string, opts?: any) => { + if (opts?.body) postCalls.push(JSON.parse(opts.body)); + return { + ok: true, + json: async () => (url.endsWith('/.well-known/jmap') + ? { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + } + : { methodResponses: [mailboxResponse] }), + }; + }); + + await handleNotificationAction({ action: 'delete', emailId: 'm1', accountId: LOCAL, jmapAccountId: 'shared' }); + + expect(postCalls).toHaveLength(1); + expect(postCalls[0].methodCalls[0][0]).toBe('Mailbox/get'); + expect(postCalls[0].methodCalls[0][1].accountId).toBe('shared'); + }); }); describe('pushes for device sync (#34)', () => { diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 56aa87ec..4afcad23 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -593,7 +593,7 @@ export async function handleNotificationAction(data: unknown): Promise { const emailAccountId = jmapAccountId ?? session.jmapAccountId; if (action === 'markRead') { - await jmapPost(session, [ + const res = await jmapPost(session, [ [ 'Email/set', { @@ -605,41 +605,42 @@ export async function handleNotificationAction(data: unknown): Promise { '0', ], ]); + assertEmailUpdated(res, emailId); } else if (action === 'delete') { const mbRes = await jmapPost(session, [ ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], ]); - const [, mbBody] = mbRes[0] ?? []; - const mailboxes = ((mbBody?.list as Mailbox[]) ?? []); + const [mbName, mbBody] = mbRes[0] ?? []; + const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : []; const trash = mailboxes.find((m) => m.role === 'trash'); - - if (trash) { - await jmapPost(session, [ - [ - 'Email/set', - { - accountId: emailAccountId, - update: { - [emailId]: { mailboxIds: { [trash.id]: true } }, - }, - }, - '0', - ], - ]); - } else { - await jmapPost(session, [ - [ - 'Email/set', - { - accountId: emailAccountId, - destroy: [emailId], - }, - '0', - ], - ]); + // Never destroy from a notification: without a Trash (a shared + // account, or a Mailbox/get that failed) the message stays put. + if (!trash) { + console.warn('[push] no trash mailbox for notification delete', emailAccountId); + return; } + const res = await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { mailboxIds: { [trash.id]: true } }, + }, + }, + '0', + ], + ]); + assertEmailUpdated(res, emailId); } } catch (err) { console.warn('[push] notification action failed', action, emailId, err); } } + +function assertEmailUpdated(responses: Array<[string, Record, string]>, emailId: string): void { + const [name, body] = responses[0] ?? []; + if (name !== 'Email/set' || !body?.updated || !(emailId in body.updated)) { + throw new Error(`Email/set did not update ${emailId}: ${JSON.stringify(body?.notUpdated?.[emailId] ?? body ?? null)}`); + } +}