Merge remote-tracking branch 'origin/main'

This commit is contained in:
Linus Rath
2026-10-10 23:20:25 +02:00
18 changed files with 1194 additions and 45 deletions
+2
View File
@@ -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 } : {}),
});
}
+1
View File
@@ -76,6 +76,7 @@
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
@@ -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
@@ -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"
}
}
@@ -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"
}
+6
View File
@@ -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.
+3
View File
@@ -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.",
+49 -4
View File
@@ -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({
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<MapPin size={16} color={c.textMuted} />
</View>
<Text
style={[styles.detailText, styles.detailLink]}
numberOfLines={3}
onPress={openLocation}
onLongPress={copyLocation}
accessibilityRole="link"
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
>
{location}
</Text>
</View>
) : null}
{videoUri ? (
<View style={styles.detailRow}>
@@ -274,7 +313,10 @@ export function EventDetailSheet({
style={styles.joinBtn}
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
>
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
<Text style={styles.joinBtnText}>
{t('calendar.detail.open_link', 'Open link')}
{meeting?.provider ? ` · ${meeting.provider}` : ''}
</Text>
</Pressable>
</View>
) : null}
@@ -414,6 +456,9 @@ export function EventDetailSheet({
)}
</SafeAreaView>
</Animated.View>
{/* The screen's own toast host sits under this Modal; this one shows
the copy confirmation above the sheet. */}
<ToastHost />
</Modal>
);
}
+5 -1
View File
@@ -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
<TextInput
value={text}
onChangeText={setText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.textMuted}
style={styles.input}
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
findMeetingLink,
isMeetingLabel,
locationAction,
mapsUrl,
meetingProviderOf,
primaryLocationName,
unwrapSafeLink,
} from '../event-links';
import { isSafeExternalUrl } from '../open-url';
/**
* A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves
* the join URL in the description, next to help and dial-in links. The sheet
* must still offer a single "Join" button, and a real address must open the
* maps app.
*/
const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d';
const TEAMS_DESCRIPTION = [
'________________________________________________________________________________',
'Réunion Microsoft Teams',
'Rejoindre sur votre ordinateur, application mobile ou appareil de salle',
`Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`,
'ID de la réunion : 319 560 123 45',
'Code secret : CTYa9',
'Télécharger Teams<https://www.microsoft.com/fr-fr/microsoft-teams/download-app> | Rejoindre sur le web<https://www.microsoft.com/microsoft-teams/join-a-meeting>',
'En savoir plus<https://aka.ms/JoinTeamsMeeting> | Options de réunion<https://teams.microsoft.com/meetingOptions/?organizerId=1>',
].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');
});
});
+164
View File
@@ -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<string, unknown>;
let metadata: Record<string, unknown>;
let bundle: Record<string, string>;
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<Response>][] = [
['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();
});
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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)', () => {
+8
View File
@@ -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,
+170
View File
@@ -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<string, { name?: string | null; description?: string | null } | null> | null;
virtualLocations?: Record<string, { uri?: string | null } | null> | 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<EventPlaces, 'locations'>): 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}`;
}
+61 -6
View File
@@ -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<boolean> {
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
// (`<webmail>/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
// (`<webmail>/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;
+105
View File
@@ -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<string, unknown>;
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<void> {
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, any>, 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)}`);
}
}
+2
View File
@@ -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';
}
/**
+102 -30
View File
@@ -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 */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
{kbObstruction === 0 && (
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
)}
</>
)}
@@ -917,6 +957,7 @@ interface EmailPaneProps {
scheduleMarkRead: (email: Email) => () => void;
styles: ReturnType<typeof makeStyles>;
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<ScrollView>(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 (
<ScrollView
ref={scrollRef}
style={styles.scroll}
// At least a page tall: a single message's body takes the space below
// its header, and the quick reply stays at the bottom however the body
// grows or shrinks once it has measured itself.
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomBarHeight + spacing.lg }]}
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomPadding }]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled={!pinching}
>
{/* Subject block */}
@@ -1112,6 +1182,8 @@ function EmailPane({
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
onFocus={handleQuickReplyFocus}
onBlur={handleQuickReplyBlur}
/>
)}
</View>