From d2f1bbf42e963e6540c18a8d1539d7a263297a68 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 21 Jul 2026 17:44:38 +0545 Subject: [PATCH 1/3] OUT-4000 | Filter out email notifications according to IU notification preference Complete OUT-3929 IU email gating now that Copilot exposes GET /v1/internal-users/:id/notification-settings. Hybrid gating, split by send path: - Single emails: attach the resolved notificationSettingId on the body so the platform gates the send (re-enabled in NotificationService). - Grouped summaries: read the recipient IU's prefs and drop events for categories whose email is disabled before composing the summary (filterEventsForIuPreferences), fail-open on read error. - add CopilotAPI.getInternalUserNotificationSettings (not cached; live pref) - add InternalUserNotificationSettingsSchema - add iuEmailPreference helpers (disabledEmailSettingIds, isIuEmailGloballyOff) - tests for the helpers and the flush filter; flip obsolete ungated guard Co-Authored-By: Claude Opus 4.8 (1M context) --- .../notification/iuEmailPreference.test.ts | 54 +++++++++++++++ src/app/api/notification/iuEmailPreference.ts | 14 ++++ .../notification/notification.service.test.ts | 8 +-- .../api/notification/notification.service.ts | 16 +++-- .../notifications/flush-grouped-email.test.ts | 68 ++++++++++++++++++- src/jobs/notifications/flush-grouped-email.ts | 38 +++++++++-- src/types/common.ts | 16 +++++ src/utils/CopilotAPI.ts | 12 ++++ 8 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 src/app/api/notification/iuEmailPreference.test.ts create mode 100644 src/app/api/notification/iuEmailPreference.ts diff --git a/src/app/api/notification/iuEmailPreference.test.ts b/src/app/api/notification/iuEmailPreference.test.ts new file mode 100644 index 000000000..1f2649adf --- /dev/null +++ b/src/app/api/notification/iuEmailPreference.test.ts @@ -0,0 +1,54 @@ +import { InternalUserNotificationSettings } from '@/types/common' +import { disabledEmailSettingIds, isIuEmailGloballyOff } from './iuEmailPreference' + +const settings = (overrides: Partial = {}): InternalUserNotificationSettings => ({ + emailSettings: 'active', + notifyAbout: {}, + ...overrides, +}) + +describe('disabledEmailSettingIds', () => { + it('collects ids only for categories with email disabled', () => { + const result = disabledEmailSettingIds( + settings({ + notifyAbout: { + newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' }, + newTaskAssigned: { disableEmail: false, notificationSettingId: 'setting_assigned' }, + taskCompleted: { disableEmail: true, notificationSettingId: 'setting_completed' }, + }, + }), + ) + + expect(result).toEqual(new Set(['setting_comment', 'setting_completed'])) + }) + + it('ignores platform categories without a notificationSettingId', () => { + const result = disabledEmailSettingIds( + settings({ + notifyAbout: { + newMessages: { disableEmail: true }, + newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' }, + }, + }), + ) + + expect(result).toEqual(new Set(['setting_comment'])) + }) + + it('returns an empty set when nothing is disabled or notifyAbout is empty', () => { + expect(disabledEmailSettingIds(settings())).toEqual(new Set()) + expect( + disabledEmailSettingIds( + settings({ notifyAbout: { newCommentOnATask: { disableEmail: false, notificationSettingId: 'setting_comment' } } }), + ), + ).toEqual(new Set()) + }) +}) + +describe('isIuEmailGloballyOff', () => { + it('is true only when emailSettings is not_active', () => { + expect(isIuEmailGloballyOff(settings({ emailSettings: 'not_active' }))).toBe(true) + expect(isIuEmailGloballyOff(settings({ emailSettings: 'active' }))).toBe(false) + expect(isIuEmailGloballyOff(settings({ emailSettings: undefined }))).toBe(false) + }) +}) diff --git a/src/app/api/notification/iuEmailPreference.ts b/src/app/api/notification/iuEmailPreference.ts new file mode 100644 index 000000000..7e2b86a71 --- /dev/null +++ b/src/app/api/notification/iuEmailPreference.ts @@ -0,0 +1,14 @@ +import { InternalUserNotificationSettings } from '@/types/common' + +// Setting ids for which this IU has turned email off. Matched by notificationSettingId (our app's +// categories carry it), so this is independent of the platform's internal category key names. +export const disabledEmailSettingIds = (settings: InternalUserNotificationSettings): Set => { + const ids = Object.values(settings.notifyAbout) + .filter((entry) => entry.notificationSettingId && entry.disableEmail) + .map((entry) => entry.notificationSettingId as string) + return new Set(ids) +} + +// Global email gate: the IU has not activated email notifications at all. +export const isIuEmailGloballyOff = (settings: InternalUserNotificationSettings): boolean => + settings.emailSettings === 'not_active' diff --git a/src/app/api/notification/notification.service.test.ts b/src/app/api/notification/notification.service.test.ts index acda3543b..b28a6051b 100644 --- a/src/app/api/notification/notification.service.test.ts +++ b/src/app/api/notification/notification.service.test.ts @@ -432,13 +432,13 @@ describe('guard: IU wiring boundaries', () => { }) }) -describe('guard: IU notifications ship ungated (settingId gating disabled)', () => { - it('does not attach notificationSettingId to the in-product dispatch or the buffered email', async () => { +describe('guard: IU notifications carry the resolved notificationSettingId (platform gating)', () => { + it('attaches the resolved notificationSettingId to the in-product dispatch and the buffered email', async () => { const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null }) await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false }) - expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBeUndefined() - expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBeUndefined() + expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBe('setting_assigned') + expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBe('setting_assigned') }) it('still buffers the IU email and fires the in-product notification', async () => { diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index f65b28211..cd2a1de65 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -14,7 +14,7 @@ import APIError from '@api/core/exceptions/api' import { BaseService } from '@api/core/services/base.service' import { NotificationTaskActions } from '@api/core/types/tasks' import { getEmailDetails, getInProductNotificationDetails, mergeEmailOverride } from '@api/notification/notification.helpers' -// import { resolveIuNotificationSettingId } from '@api/notification/resolveNotificationSettingId' +import { resolveIuNotificationSettingId } from '@api/notification/resolveNotificationSettingId' import { AssigneeType, ClientNotification, GroupedEmailEventType, Prisma, Task } from '@prisma/client' import { randomUUID } from 'crypto' import { enqueueGroupedEmailFlush } from '@/jobs/notifications/flush-grouped-email' @@ -74,9 +74,10 @@ export class NotificationService extends BaseService { const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail const category = this.groupedEventTypeFor(action) - // TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now. - const notificationSettingId = undefined - // const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined + const notificationSettingId = + isRecipientIu && category + ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) + : undefined const groupedType = email && recipientId ? category : null if (groupedType) { @@ -201,9 +202,10 @@ export class NotificationService extends BaseService { const association = AssociationsSchema.parse(task.associations)?.[0] const category = this.groupedEventTypeFor(action) const isRecipientIu = opts.isRecipientIu - // TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now. - const notificationSettingId = undefined - // const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined + const notificationSettingId = + isRecipientIu && category + ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) + : undefined // Non-null only when these emails should be diverted into the grouped buffer. const groupedType = email ? category : null diff --git a/src/jobs/notifications/flush-grouped-email.test.ts b/src/jobs/notifications/flush-grouped-email.test.ts index ec3bcfafe..6f8d6a323 100644 --- a/src/jobs/notifications/flush-grouped-email.test.ts +++ b/src/jobs/notifications/flush-grouped-email.test.ts @@ -6,6 +6,7 @@ const mockQueryRaw = jest.fn() const mockExecuteRaw = jest.fn() const mockFindManyTask = jest.fn() const mockGetInternalUsers = jest.fn() +const mockGetIuNotificationSettings = jest.fn() const mockCaptureException = jest.fn() jest.mock('@trigger.dev/sdk/v3', () => ({ @@ -35,9 +36,11 @@ jest.mock('@/lib/db', () => ({ })) jest.mock('@/utils/CopilotAPI', () => ({ - CopilotAPI: jest - .fn() - .mockImplementation(() => ({ getInternalUsers: mockGetInternalUsers, createNotification: mockCreateNotification })), + CopilotAPI: jest.fn().mockImplementation(() => ({ + getInternalUsers: mockGetInternalUsers, + createNotification: mockCreateNotification, + getInternalUserNotificationSettings: mockGetIuNotificationSettings, + })), })) jest.mock('./send-grouped-email', () => ({ @@ -83,6 +86,8 @@ beforeEach(() => { jest.clearAllMocks() seq = 0 mockGetInternalUsers.mockResolvedValue({ data: [{ id: 'iu_1' }] }) + // default: IU has all email categories enabled + mockGetIuNotificationSettings.mockResolvedValue({ emailSettings: 'active', notifyAbout: {} }) mockSendGroupedEmail.mockResolvedValue('notif_1') mockCreateNotification.mockResolvedValue({ id: 'notif_1' }) mockExecuteRaw.mockResolvedValue(1) @@ -161,6 +166,63 @@ describe('flushGroupedEmailRun', () => { expect(mockSendGroupedEmail.mock.calls[0][0].notificationSettingId).toBeUndefined() }) + it('drops events for categories the IU disabled email on, keeping the rest', async () => { + mockGetIuNotificationSettings.mockResolvedValue({ + emailSettings: 'active', + notifyAbout: { + newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' }, + newTaskAssigned: { disableEmail: false, notificationSettingId: 'setting_assigned' }, + }, + }) + mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')]) + + await flushGroupedEmailRun(payload) + + // Only the assignment survives -> replayed as a single individual email, not a grouped summary. + expect(mockSendGroupedEmail).not.toHaveBeenCalled() + expect(mockCreateNotification).toHaveBeenCalledTimes(1) + expect(mockCreateNotification.mock.calls[0][0]).toMatchObject({ notificationSettingId: 'setting_assigned' }) + }) + + it('sends nothing to an IU who disabled email on every buffered category', async () => { + mockGetIuNotificationSettings.mockResolvedValue({ + emailSettings: 'active', + notifyAbout: { + newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' }, + newTaskAssigned: { disableEmail: true, notificationSettingId: 'setting_assigned' }, + }, + }) + mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')]) + + const result = await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail).not.toHaveBeenCalled() + expect(mockCreateNotification).not.toHaveBeenCalled() + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows + expect(result).toMatchObject({ recipients: 1, sent: 0, sentGrouped: 0, sentIndividual: 0 }) + }) + + it('sends nothing to an IU whose email is globally not active', async () => { + mockGetIuNotificationSettings.mockResolvedValue({ emailSettings: 'not_active', notifyAbout: {} }) + mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')]) + + const result = await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail).not.toHaveBeenCalled() + expect(mockCreateNotification).not.toHaveBeenCalled() + expect(result).toMatchObject({ sent: 0 }) + }) + + it('sends ungated when the IU preference read fails (fail-open)', async () => { + mockGetIuNotificationSettings.mockRejectedValue(new Error('copilot 5xx')) + mockQueryRaw.mockResolvedValue([iuRow('setting_comment'), iuRow('setting_comment')]) + + await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail).toHaveBeenCalledTimes(1) + expect(mockSendGroupedEmail.mock.calls[0][0].content.totalEventCount).toBe(2) + }) + it('replays the original individual email when the window has a single live event', async () => { mockQueryRaw.mockResolvedValue([row()]) diff --git a/src/jobs/notifications/flush-grouped-email.ts b/src/jobs/notifications/flush-grouped-email.ts index 496ebc4c9..fd9c66241 100644 --- a/src/jobs/notifications/flush-grouped-email.ts +++ b/src/jobs/notifications/flush-grouped-email.ts @@ -3,6 +3,7 @@ import 'server-only' import { randomUUID } from 'crypto' import { composeGroupedEmail, GroupedEmailEventInput } from '@/app/api/notification/groupedEmail.composer' +import { disabledEmailSettingIds, isIuEmailGloballyOff } from '@/app/api/notification/iuEmailPreference' import { copilotAPIKey } from '@/config' import { Sentry } from '@/jobs/sentry' import DBClient from '@/lib/db' @@ -87,12 +88,31 @@ const senderFromEvents = (events: WindowEvent[]): EventSender | undefined => { return { senderId: email.senderId, senderType: email.senderType, senderCompanyId: email.senderCompanyId } } -// Seam for per-IU category filtering. Today it's a pass-through: the platform can't gate a mixed -// grouped summary, and there's no API to read an IU's per-setting preferences. Once Assembly exposes -// that endpoint, fetch the recipient IU's prefs here and drop events for disabled categories so the -// summary only carries allowed ones (an all-disabled recipient then sends nothing). -// TODO(OUT-3929): implement per-IU filtering when the read endpoint lands. -const filterEventsForIuPreferences = (events: WindowEvent[]): WindowEvent[] => events +// Per-IU category filtering for grouped summaries. The platform can only gate a send that carries +// one setting id, so a mixed-category window is gated here instead: read the recipient IU's prefs +// and drop events for categories whose email is disabled (an all-disabled recipient sends nothing). +// Matched by the notificationSettingId the buffered email already carries. Fails open on read error. +const filterEventsForIuPreferences = async ( + events: WindowEvent[], + recipientIuId: string, + copilot: CopilotAPI, +): Promise => { + try { + const settings = await copilot.getInternalUserNotificationSettings(recipientIuId) + if (isIuEmailGloballyOff(settings)) return [] + const disabled = disabledEmailSettingIds(settings) + return events.filter((e) => { + const id = e.individualEmail?.notificationSettingId + return !id || !disabled.has(id) + }) + } catch (e) { + logger.error('flush-grouped-email: failed to read IU prefs; sending ungated', { + recipientIuId, + error: serializeError(e), + }) + return events + } +} // The platform can only gate a send that carries one setting id. A grouped summary gets an id only // when every live event shares the same one (i.e. a single-category window); a mixed window sends @@ -232,7 +252,11 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => } for (const group of iuGroups) { - const liveEvents = filterEventsForIuPreferences(group.events.filter((e) => liveTaskIds.has(e.taskId))) + const liveEvents = await filterEventsForIuPreferences( + group.events.filter((e) => liveTaskIds.has(e.taskId)), + group.recipientIuId, + copilot, + ) // A single event replays its buffered email verbatim (it already carries its own setting id). const singleEmail = liveEvents.length === 1 ? liveEvents[0].individualEmail : null diff --git a/src/types/common.ts b/src/types/common.ts index 119adb64f..2d9028af7 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -237,6 +237,22 @@ export const NotificationSettingsResponseSchema = z.object({ }) export type NotificationSettingsResponse = z.infer +// A single IU's per-category notification preference (GET /v1/internal-users/:id/notification-settings). +// Only our app's categories carry appId + notificationSettingId; the platform's own categories don't. +export const IuNotifyAboutEntrySchema = z.object({ + disableInProduct: z.boolean().optional(), + disableEmail: z.boolean().optional(), + appId: z.string().optional(), + notificationSettingId: z.string().optional(), +}) + +export const InternalUserNotificationSettingsSchema = z.object({ + disableInProduct: z.boolean().optional(), + emailSettings: z.string().optional(), + notifyAbout: z.record(z.string(), IuNotifyAboutEntrySchema).default({}), +}) +export type InternalUserNotificationSettings = z.infer + export const ScrapMediaRequestSchema = z.object({ filePath: z.string(), taskId: z.string().uuid().optional(), diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index 82e4ed7e6..eed475e79 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -19,6 +19,8 @@ import { CopilotListArgs, CustomFieldResponse, CustomFieldResponseSchema, + InternalUserNotificationSettings, + InternalUserNotificationSettingsSchema, InternalUsers, InternalUsersResponse, InternalUsersResponseSchema, @@ -370,6 +372,15 @@ export class CopilotAPI { return NotificationSettingsResponseSchema.parse(response) } + // A single IU's live per-category notification preferences. Never cached — the platform evaluates + // this on every send and IUs can toggle it at any time. + async _getInternalUserNotificationSettings(id: string): Promise { + console.info('CopilotAPI#_getInternalUserNotificationSettings', id) + const workspaceId = await this._resolveWorkspaceId() + const response = await this._manualFetch(`internal-users/${id}/notification-settings`, undefined, workspaceId) + return InternalUserNotificationSettingsSchema.parse(response) + } + async dispatchWebhook( eventName: DISPATCHABLE_EVENT, { @@ -435,6 +446,7 @@ export class CopilotAPI { manualFetch = this.wrapWithRetry(this._manualFetch) getIUNotification = this.wrapWithRetry(this._getIUNotification) getNotificationSettings = this.wrapWithRetry(this._getNotificationSettings) + getInternalUserNotificationSettings = this.wrapWithRetry(this._getInternalUserNotificationSettings) } const cachedFetchInternalUser = cache( From 5bf59965b9b20fe4d2933909fd2f30ef8b31f8a5 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 21 Jul 2026 17:50:44 +0545 Subject: [PATCH 2/3] OUT-4000 | Fail closed on the global IU email gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only treat email as globally enabled when emailSettings is explicitly "active" (case-insensitive, trimmed). Any other value — a different disabled string, unexpected casing, or a missing field — is treated as not activated, so a global opt-out is never bypassed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/notification/iuEmailPreference.test.ts | 13 ++++++++++--- src/app/api/notification/iuEmailPreference.ts | 6 ++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/api/notification/iuEmailPreference.test.ts b/src/app/api/notification/iuEmailPreference.test.ts index 1f2649adf..e04863c4b 100644 --- a/src/app/api/notification/iuEmailPreference.test.ts +++ b/src/app/api/notification/iuEmailPreference.test.ts @@ -46,9 +46,16 @@ describe('disabledEmailSettingIds', () => { }) describe('isIuEmailGloballyOff', () => { - it('is true only when emailSettings is not_active', () => { - expect(isIuEmailGloballyOff(settings({ emailSettings: 'not_active' }))).toBe(true) + it('is false only when emailSettings is explicitly active (case-insensitive)', () => { expect(isIuEmailGloballyOff(settings({ emailSettings: 'active' }))).toBe(false) - expect(isIuEmailGloballyOff(settings({ emailSettings: undefined }))).toBe(false) + expect(isIuEmailGloballyOff(settings({ emailSettings: 'Active' }))).toBe(false) + expect(isIuEmailGloballyOff(settings({ emailSettings: ' ACTIVE ' }))).toBe(false) + }) + + it('fails closed for any non-active value so a global opt-out is never bypassed', () => { + expect(isIuEmailGloballyOff(settings({ emailSettings: 'not_active' }))).toBe(true) + expect(isIuEmailGloballyOff(settings({ emailSettings: 'disabled' }))).toBe(true) + expect(isIuEmailGloballyOff(settings({ emailSettings: undefined }))).toBe(true) + expect(isIuEmailGloballyOff(settings({ emailSettings: '' }))).toBe(true) }) }) diff --git a/src/app/api/notification/iuEmailPreference.ts b/src/app/api/notification/iuEmailPreference.ts index 7e2b86a71..46581c17a 100644 --- a/src/app/api/notification/iuEmailPreference.ts +++ b/src/app/api/notification/iuEmailPreference.ts @@ -9,6 +9,8 @@ export const disabledEmailSettingIds = (settings: InternalUserNotificationSettin return new Set(ids) } -// Global email gate: the IU has not activated email notifications at all. +// Global email gate, fail-closed: email is delivered only when the IU has explicitly activated +// email notifications. Any other value — a different disabled string, unexpected casing, or a +// missing field — is treated as not activated, so a global opt-out is never bypassed. export const isIuEmailGloballyOff = (settings: InternalUserNotificationSettings): boolean => - settings.emailSettings === 'not_active' + (settings.emailSettings ?? '').trim().toLowerCase() !== 'active' From f5d34a9d7ce7ef7b950d7c2f03cbe3479fffbd5e Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 21 Jul 2026 18:18:55 +0545 Subject: [PATCH 3/3] OUT-4000 | Drop the emailSettings global gate; rely on per-category disableEmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emailSettings "not_active" does NOT mean the IU receives no email — a test IU with emailSettings=not_active still expects (and should get) comment emails. Gating on it suppressed every grouped IU email for that IU. The reliable signal is the per-category disableEmail flag, so gate on that alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/notification/iuEmailPreference.test.ts | 17 +---------------- src/app/api/notification/iuEmailPreference.ts | 6 ------ .../notifications/flush-grouped-email.test.ts | 11 ----------- src/jobs/notifications/flush-grouped-email.ts | 3 +-- 4 files changed, 2 insertions(+), 35 deletions(-) diff --git a/src/app/api/notification/iuEmailPreference.test.ts b/src/app/api/notification/iuEmailPreference.test.ts index e04863c4b..d443647c1 100644 --- a/src/app/api/notification/iuEmailPreference.test.ts +++ b/src/app/api/notification/iuEmailPreference.test.ts @@ -1,5 +1,5 @@ import { InternalUserNotificationSettings } from '@/types/common' -import { disabledEmailSettingIds, isIuEmailGloballyOff } from './iuEmailPreference' +import { disabledEmailSettingIds } from './iuEmailPreference' const settings = (overrides: Partial = {}): InternalUserNotificationSettings => ({ emailSettings: 'active', @@ -44,18 +44,3 @@ describe('disabledEmailSettingIds', () => { ).toEqual(new Set()) }) }) - -describe('isIuEmailGloballyOff', () => { - it('is false only when emailSettings is explicitly active (case-insensitive)', () => { - expect(isIuEmailGloballyOff(settings({ emailSettings: 'active' }))).toBe(false) - expect(isIuEmailGloballyOff(settings({ emailSettings: 'Active' }))).toBe(false) - expect(isIuEmailGloballyOff(settings({ emailSettings: ' ACTIVE ' }))).toBe(false) - }) - - it('fails closed for any non-active value so a global opt-out is never bypassed', () => { - expect(isIuEmailGloballyOff(settings({ emailSettings: 'not_active' }))).toBe(true) - expect(isIuEmailGloballyOff(settings({ emailSettings: 'disabled' }))).toBe(true) - expect(isIuEmailGloballyOff(settings({ emailSettings: undefined }))).toBe(true) - expect(isIuEmailGloballyOff(settings({ emailSettings: '' }))).toBe(true) - }) -}) diff --git a/src/app/api/notification/iuEmailPreference.ts b/src/app/api/notification/iuEmailPreference.ts index 46581c17a..e9a17af7e 100644 --- a/src/app/api/notification/iuEmailPreference.ts +++ b/src/app/api/notification/iuEmailPreference.ts @@ -8,9 +8,3 @@ export const disabledEmailSettingIds = (settings: InternalUserNotificationSettin .map((entry) => entry.notificationSettingId as string) return new Set(ids) } - -// Global email gate, fail-closed: email is delivered only when the IU has explicitly activated -// email notifications. Any other value — a different disabled string, unexpected casing, or a -// missing field — is treated as not activated, so a global opt-out is never bypassed. -export const isIuEmailGloballyOff = (settings: InternalUserNotificationSettings): boolean => - (settings.emailSettings ?? '').trim().toLowerCase() !== 'active' diff --git a/src/jobs/notifications/flush-grouped-email.test.ts b/src/jobs/notifications/flush-grouped-email.test.ts index 6f8d6a323..e05b33a31 100644 --- a/src/jobs/notifications/flush-grouped-email.test.ts +++ b/src/jobs/notifications/flush-grouped-email.test.ts @@ -202,17 +202,6 @@ describe('flushGroupedEmailRun', () => { expect(result).toMatchObject({ recipients: 1, sent: 0, sentGrouped: 0, sentIndividual: 0 }) }) - it('sends nothing to an IU whose email is globally not active', async () => { - mockGetIuNotificationSettings.mockResolvedValue({ emailSettings: 'not_active', notifyAbout: {} }) - mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')]) - - const result = await flushGroupedEmailRun(payload) - - expect(mockSendGroupedEmail).not.toHaveBeenCalled() - expect(mockCreateNotification).not.toHaveBeenCalled() - expect(result).toMatchObject({ sent: 0 }) - }) - it('sends ungated when the IU preference read fails (fail-open)', async () => { mockGetIuNotificationSettings.mockRejectedValue(new Error('copilot 5xx')) mockQueryRaw.mockResolvedValue([iuRow('setting_comment'), iuRow('setting_comment')]) diff --git a/src/jobs/notifications/flush-grouped-email.ts b/src/jobs/notifications/flush-grouped-email.ts index fd9c66241..b40a69784 100644 --- a/src/jobs/notifications/flush-grouped-email.ts +++ b/src/jobs/notifications/flush-grouped-email.ts @@ -3,7 +3,7 @@ import 'server-only' import { randomUUID } from 'crypto' import { composeGroupedEmail, GroupedEmailEventInput } from '@/app/api/notification/groupedEmail.composer' -import { disabledEmailSettingIds, isIuEmailGloballyOff } from '@/app/api/notification/iuEmailPreference' +import { disabledEmailSettingIds } from '@/app/api/notification/iuEmailPreference' import { copilotAPIKey } from '@/config' import { Sentry } from '@/jobs/sentry' import DBClient from '@/lib/db' @@ -99,7 +99,6 @@ const filterEventsForIuPreferences = async ( ): Promise => { try { const settings = await copilot.getInternalUserNotificationSettings(recipientIuId) - if (isIuEmailGloballyOff(settings)) return [] const disabled = disabledEmailSettingIds(settings) return events.filter((e) => { const id = e.individualEmail?.notificationSettingId