Honor muted boards for every notification source

Apply the board watch level at the final recipient boundary so direct assignments, mentions, card or list watchers, and broad-event notifications cannot send mail or profile notifications to muted users. Keep Watching and Tracking active members eligible, and cover missing/default-muted, inactive and non-member recipients.

Thanks to Nissulya and xet7 !

Fixes #6658,
This commit is contained in:
Lauri Ojansivu committed 2026-08-31 13:27:15 +03:00
1 parent d63a942851
commit 4be77e7189
3 files changed
+86 -1

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
'use strict';
const NOTIFYING_LEVELS = new Set(['watching', 'tracking']);
/**
* Keep notification candidates who are active board members and whose board
* watch level permits notifications. A missing watcher entry has the board's
* default `muted` meaning and must not be bypassed by assignment, mentions,
* card/list watching or BIGEVENTS_PATTERN.
*/
function boardNotificationRecipients(candidates, members, boardWatchers) {
const activeMemberIds = new Set(
(members || []).filter(member => member.isActive === true).map(member => member.userId),
);
const notifyingUserIds = new Set(
(boardWatchers || [])
.filter(watcher => NOTIFYING_LEVELS.has(watcher.level))
.map(watcher => watcher.userId),
);
return [...new Set(candidates || [])].filter(
userId => activeMemberIds.has(userId) && notifyingUserIds.has(userId),
);
}
module.exports = { boardNotificationRecipients };
+12 -1
View File
@@ -12,6 +12,9 @@ import { safeDeliver } from '/server/lib/webhookGuard';
import { labelDisplayName } from '/models/lib/labelDisplayName';
import { getFeatureFlags } from '/models/lib/featureFlags';
import { ACTIVITY_NOTIFICATION_TITLE } from '/server/lib/activityNotificationTitle';
const {
boardNotificationRecipients,
} = require('/models/lib/boardNotificationRecipients');
function normalizeActivityText(value, fallback = '') {
return typeof value === 'string' ? value : fallback;
@@ -393,7 +396,15 @@ Activities.after.insert(async (userId, doc) => {
}
}
watchers = watchers.filter(x => activeMemberIds.includes(x));
// #6658: Muted is the board's final notification boundary. Direct
// assignment (#5833), mentions, list/card watchers and BIGEVENTS_PATTERN
// may nominate a recipient, but none may override that recipient's board
// watch level. No watcher entry also means the default `muted` level.
watchers = boardNotificationRecipients(
watchers,
board.members,
board.watchers,
);
}
(await Notifications.getUsers(watchers)).forEach((user) => {
@@ -0,0 +1,48 @@
'use strict';
const assert = require('node:assert/strict');
const {
boardNotificationRecipients,
} = require('../models/lib/boardNotificationRecipients');
const members = [
{ userId: 'watching', isActive: true },
{ userId: 'tracking', isActive: true },
{ userId: 'muted', isActive: true },
{ userId: 'default-muted', isActive: true },
{ userId: 'inactive', isActive: false },
];
const boardWatchers = [
{ userId: 'watching', level: 'watching' },
{ userId: 'tracking', level: 'tracking' },
{ userId: 'muted', level: 'muted' },
{ userId: 'inactive', level: 'watching' },
];
assert.deepEqual(
boardNotificationRecipients(
['watching', 'tracking', 'watching'],
members,
boardWatchers,
),
['watching', 'tracking'],
'Watching and Tracking active members are notified once',
);
assert.deepEqual(
boardNotificationRecipients(
['muted', 'default-muted', 'inactive', 'not-a-member'],
members,
boardWatchers,
),
[],
'Muted/default-muted, inactive and non-member candidates stay silent',
);
assert.deepEqual(
boardNotificationRecipients(undefined, undefined, undefined),
[],
'missing board data cannot accidentally notify anyone',
);
console.log('boardMutedNotifications6658: muted board boundary passed');