diff --git a/App.tsx b/App.tsx index 5e152ebf..a275df46 100644 --- a/App.tsx +++ b/App.tsx @@ -119,9 +119,11 @@ 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), + ...(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..5485a398 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -76,6 +76,7 @@ + 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..2bcc8ff7 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 @@ -72,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) @@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) notificationId: String, title: String, body: String, + preview: String?, largeIcon: Bitmap, colorHex: String, emailId: String?, @@ -110,10 +118,15 @@ 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 { 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) @@ -127,12 +140,27 @@ 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) + // 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(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) @@ -142,6 +170,72 @@ 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) + // Android 12+: unlock first, so a locked phone can't delete mail. + .setAuthenticationRequired(true) + .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()) } @@ -157,7 +251,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 +270,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( @@ -273,6 +372,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..ae210a4e --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -0,0 +1,124 @@ +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 + if (notificationId != null) { + manager.cancel(notificationId, notificationId.hashCode()) + } + + 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 { + 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.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/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/locales/rn/en.json b/locales/rn/en.json index e31712a4..f0a0113b 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -13,6 +13,9 @@ "indigo": "Indigo", "teal": "Teal" }, + "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 05486255..eaa7ef07 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -4,14 +4,19 @@ import { Dimensions, Easing, Modal, + Platform, Pressable, ScrollView, StyleSheet, Text, 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'; import { AlignLeft, @@ -184,8 +189,11 @@ export function EventDetailSheet({ const recurrence = recurrenceLabel(event, t, locale); const reminders = alertsToReminders(event.alerts); const participants = getParticipantList(event, { resolveName: resolveContactName }); - 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 @@ -207,6 +215,23 @@ 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(() => { + toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard')); + }).catch(() => {}); + }; + const doRsvp = async (status: RsvpStatus) => { if (!onRsvp || !myParticipantId || rsvpBusy) return; setRsvpBusy(true); @@ -263,7 +288,21 @@ export function EventDetailSheet({ > } text={range} /> {location ? ( - } text={location} /> + + + + + + {location} + + ) : null} {videoUri ? ( @@ -274,7 +313,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} @@ -414,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. */} + ); } 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 `, + '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('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'); + }); + + 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('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); + } + }); +}); + +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/__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/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index b8298866..676ff2e7 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,133 @@ 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'], + ]); + }); + + 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'], + ]); + }); + + 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/__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/lib/event-links.ts b/src/lib/event-links.ts new file mode 100644 index 00000000..53f4afc9 --- /dev/null +++ b/src/lib/event-links.ts @@ -0,0 +1,170 @@ +/** + * 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 { isSafeExternalUrl } from './open-url'; + +/** 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; + /** 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|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: /^\/./ }, + { 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: EventPlaces): MeetingLink | null { + for (const vl of Object.values(event.virtualLocations ?? {})) { + const uri = vl?.uri; + if (isHttpUrl(uri)) { + return { uri: uri.trim(), provider: meetingProviderOf(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; + } + const inDescription = firstMeetingUrl(event.description); + if (inDescription) return inDescription; + // 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(); + if (isSafeExternalUrl(uri)) return { uri, derived: false }; + } + return null; +} + +/** 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. 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 { + 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 = + | { 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}`; +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index c66be1d8..a31db07c 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -104,8 +104,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); } @@ -133,6 +133,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({ @@ -206,7 +260,7 @@ export async function runWebmailHandoff( if (!accessToken || !tokenEndpoint || !clientId) { throw new HandoffError('Sign-in response missing OAuth tokens'); } - if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) { + if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) { throw new HandoffError('Sign-in response token endpoint is not trusted'); } const refreshToken = params.get('refresh_token') ?? undefined; @@ -455,9 +509,10 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi if (!accessToken || !tokenEndpoint || !clientId) { throw new PairingError('bad_response', 'Pairing response missing token material', { host }); } - // The mail server's own host, or the webmail's token proxy - // (`/api/auth/pair/token`); nothing else gets the refresh token. - if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, base)) { + // The mail server's own host, the webmail's token proxy + // (`/api/auth/pair/token`), or an external identity provider the + // webmail itself names; nothing else gets the refresh token. + if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, base, clientId))) { throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host }); } const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined; diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 1f513bc9..4afcad23 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,94 @@ 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 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 { + 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) { + console.warn('[push] failed to open detached session for account', accountId); + return; + } + const emailAccountId = jmapAccountId ?? session.jmapAccountId; + + if (action === 'markRead') { + const res = await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { 'keywords/$seen': true }, + }, + }, + '0', + ], + ]); + assertEmailUpdated(res, emailId); + } else if (action === 'delete') { + const mbRes = await jmapPost(session, [ + ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], + ]); + const [mbName, mbBody] = mbRes[0] ?? []; + const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : []; + const trash = mailboxes.find((m) => m.role === 'trash'); + // 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)}`); + } +} 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'; } /** diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index aad8f3f9..cb1ed143 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator, Modal, useWindowDimensions, Animated, Easing, Alert, FlatList, + Keyboard, Dimensions, Platform, } from 'react-native'; import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -639,6 +640,42 @@ function EmailViewer({ route, navigation }: Props) { }; const bottomBarHeight = 60 + Math.max(insets.bottom, 4); + + // Track the visible keyboard obstruction so quick reply stays visible and + // the bottom bar hides while typing. On Android edge-to-edge, the IME inset + // is measured from the top of the gesture bar rather than screen bottom, + // so we derive obstruction height from Dimensions.screen. + const [kbObstruction, setKbObstruction] = React.useState(0); + React.useEffect(() => { + 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} /> )}