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}
/>
)}