feat(notifications): push for inbound mail, not just messenger

NotificationProjector now also consumes interaction.normalized and, for kind
EMAIL (external email + app-to-app mail, which land via ingest — not
message.sent), always notifies the non-sender recipient(s), reusing the same
presence + mute gates. Refactors the fan-out into a shared notify() with an
alwaysNotify flag (DM or mail); message.sent keeps its DM/mention/reply policy.
Adds specs: mail notifies in a group thread where a plain message would not, and
a non-EMAIL normalized interaction does not notify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:47:40 +05:30
parent b8bf1aa347
commit 90e37edf89
2 changed files with 89 additions and 18 deletions
@@ -33,6 +33,18 @@ async function sentEvents(): Promise<CloudEvent[]> {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
return {
specversion: '1.0',
id: `evt_norm_${interactionId}`,
type: IIOS_EVENTS.interactionNormalized,
source: 'iios/ingest/test',
time: '2026-01-01T00:00:00.000Z',
insignia: { idempotencyKey: `norm:${interactionId}` },
data: { interactionId, threadId, kind },
} as CloudEvent;
}
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
const deliver = vi.fn().mockResolvedValue(deliverResult);
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
@@ -51,7 +63,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
});
@@ -88,7 +100,7 @@ describe('NotificationProjector', () => {
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
const { proj, deliver } = makeProjector();
const evs = await sentEvents();
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
await proj.onEvent(evs[evs.length - 1]!); // project the reply
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
const presence = new PresenceService();
presence.setFocus('sockB', 'bob', threadId);
const { proj, deliver } = makeProjector(presence);
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
// A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
expect(deliver).not.toHaveBeenCalled();
});
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj } = makeProjector(new PresenceService(), 'gone');
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
});
});