From f725908053d808f7073c6c6de2dd6e1b42a305a1 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 13:32:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20GET=20/api/mail-events=20(=EC=97=94?= =?UTF-8?q?=EC=A7=84=20EventSource=20=EC=A4=91=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직원마다 엔진 EventSource 하나를 그 직원의 연결들이 나눠 쓴다(fan-out) — state · 받은편지함 안 읽음 · 새 메일 계산은 직원마다 한 번 - 새 메일: Email/changes → Email/get 으로 받은편지함 · 초안 아님 · 안 읽음 · 본인 아님 · 10분 넘게 앞서지 않은 메일만, 셀 수 없거나(cannotCalculateChanges · 400 notRequest) 2,000건 넘게 바뀌면 state 만 보내고 기준을 다시 잡는다 - 연결 5개 한도, 25초 ping(upstream), 토큰 만료 30초 전 end(남은 시간이 짧으면 끝날 때), 서버 종료 때 end, 처리 전에 끊긴 응답 정리, 처음 엔진 연결 10초 상한 - 엔진 연결: 75초 무소식이면 다시 잇기(±20%), 엔진 429 는 끊지 않고 다시 묻기, 같은 기준 3번 실패면 기준 다시 잡기, 5분 안전판 - 액세스 토큰의 만료 시각을 AuthenticatedUser 에 싣는다(선택 필드), 요청이 끝난 뒤에도 이어지는 일은 요청 컨텍스트 밖에서 Co-Authored-By: Claude Opus 5.5 --- .../decorators/current-user.decorator.ts | 5 + backend/src/modules/auth/jwt.strategy.spec.ts | 15 + backend/src/modules/auth/jwt.strategy.ts | 12 +- .../modules/mail/jmap/jmap-mail-watch.spec.ts | 646 ++++++++++++++++++ .../src/modules/mail/jmap/jmap-mail-watch.ts | 541 +++++++++++++++ .../mail/jmap/jmap-mail.provider.spec.ts | 430 +++++++++++- .../modules/mail/jmap/jmap-mail.provider.ts | 290 +++++++- .../src/modules/mail/jmap/jmap-push.spec.ts | 51 ++ backend/src/modules/mail/jmap/jmap-push.ts | 47 ++ .../src/modules/mail/jmap/jmap.client.spec.ts | 195 ++++++ backend/src/modules/mail/jmap/jmap.client.ts | 117 +++- backend/src/modules/mail/jmap/jmap.types.ts | 13 + .../src/modules/mail/jmap/new-mail.spec.ts | 174 +++++ backend/src/modules/mail/jmap/new-mail.ts | 80 +++ .../mail/mail-events.controller.spec.ts | 155 +++++ .../modules/mail/mail-events.controller.ts | 126 ++++ .../modules/mail/mail-events.service.spec.ts | 566 +++++++++++++++ .../src/modules/mail/mail-events.service.ts | 390 +++++++++++ backend/src/modules/mail/mail.module.ts | 5 + backend/src/modules/mail/mail.provider.ts | 13 + backend/src/modules/mail/mail.service.spec.ts | 1 + backend/src/modules/mail/mail.types.ts | 50 ++ .../src/shared/logger/request-context.spec.ts | 16 + backend/src/shared/logger/request-context.ts | 9 + backend/src/shared/utils/sse.spec.ts | 113 +++ backend/src/shared/utils/sse.ts | 146 ++++ backend/test/app.e2e-spec.ts | 205 +++++- backend/test/fake-event-stream.ts | 70 ++ 28 files changed, 4462 insertions(+), 19 deletions(-) create mode 100644 backend/src/modules/mail/jmap/jmap-mail-watch.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-mail-watch.ts create mode 100644 backend/src/modules/mail/jmap/jmap-push.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-push.ts create mode 100644 backend/src/modules/mail/jmap/new-mail.spec.ts create mode 100644 backend/src/modules/mail/jmap/new-mail.ts create mode 100644 backend/src/modules/mail/mail-events.controller.spec.ts create mode 100644 backend/src/modules/mail/mail-events.controller.ts create mode 100644 backend/src/modules/mail/mail-events.service.spec.ts create mode 100644 backend/src/modules/mail/mail-events.service.ts create mode 100644 backend/src/shared/utils/sse.spec.ts create mode 100644 backend/src/shared/utils/sse.ts create mode 100644 backend/test/fake-event-stream.ts diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts index 34c56c4..bd03fab 100644 --- a/backend/src/common/decorators/current-user.decorator.ts +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -9,6 +9,11 @@ export interface AuthenticatedUser { readonly email: string; /** 표시 이름 */ readonly name: string; + /** + * 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다. + * 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다 + */ + readonly accessTokenExpiresAt?: number; } /** diff --git a/backend/src/modules/auth/jwt.strategy.spec.ts b/backend/src/modules/auth/jwt.strategy.spec.ts index 0471f12..1d6b18d 100644 --- a/backend/src/modules/auth/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/jwt.strategy.spec.ts @@ -19,6 +19,21 @@ describe('JwtStrategy.validate', () => { ).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' }); }); + it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => { + const user = strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + exp: 1_793_000_000, + }); + + expect(user).toStrictEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + accessTokenExpiresAt: 1_793_000_000_000, + }); + }); + it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => { expect(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 3c298e7..f085fad 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -11,6 +11,9 @@ import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; import type { AuthTokenPayload } from './auth-token.types'; +/** JWT 의 시각(exp)은 초 단위다 */ +const MS_PER_SECOND = 1000; + /** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */ function accessTokenFromCookie(request: Request): string | null { return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null; @@ -42,6 +45,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { context: { reason: 'not-an-access-token' }, }); } - return { email: payload.sub, name: payload.name }; + return { + email: payload.sub, + name: payload.name, + // 만료(exp, 초)는 passport-jwt 가 이미 확인했다 — 오래 열린 연결이 토큰보다 오래 가지 않게 넘겨 둔다 (실시간 알림) + ...(typeof payload.exp === 'number' + ? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND } + : {}), + }; } } diff --git a/backend/src/modules/mail/jmap/jmap-mail-watch.spec.ts b/backend/src/modules/mail/jmap/jmap-mail-watch.spec.ts new file mode 100644 index 0000000..ccb66ec --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-mail-watch.spec.ts @@ -0,0 +1,646 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + MailLiveState, + MailWatch, + MailWatchListener, + NewMailNotice, +} from '@/modules/mail/mail.types'; +import { + FakeEventStream, + PING_EVENT, + settleAsync as settle, + stateChangeEvent, +} from '@/../test/fake-event-stream'; +import type { JmapEventStream } from './jmap.client'; +import { + JmapMailWatch, + WATCH_BASE_FAILURES_BEFORE_RESET, + WATCH_DEBOUNCE_MS, + WATCH_RATE_LIMIT_RETRY_MS, + WATCH_SAFETY_REFRESH_MS, + WATCH_SILENCE_MS, + WATCH_START_TIMEOUT_MS, + type WatchEngine, + type WatchPosition, + type WatchReading, +} from './jmap-mail-watch'; + +const ACCOUNT = 'd'; + +/** 이 계정의 메일 변화 신호 (Stalwart 의 StateChange) */ +const stateEvent = (account = ACCOUNT) => stateChangeEvent(account); + +const position = (emailState: string): WatchPosition => ({ + emailState, + inboxId: 'a', + ownAddresses: new Set(['ara.kim@arasystem.test']), +}); + +const reading = ( + state: string, + inboxUnread: number, + newMail: NewMailNotice | null = null, +): WatchReading => ({ + live: { state, inboxUnread }, + position: position(`email-${state}`), + newMail, +}); + +const NEW_MAIL: NewMailNotice = { + count: 1, + messages: [ + { + id: 'm1', + from: { name: '박서준', email: 'seojun.park@arasystem.test' }, + subject: '안녕하세요', + receivedAt: '2026-10-28T05:42:00.000Z', + }, + ], +}; + +type Event = + | ['state', MailLiveState] + | ['new-mail', NewMailNotice] + | ['upstream', boolean] + | ['ended']; + +function recorder(): { events: Event[]; listener: MailWatchListener } { + const events: Event[] = []; + return { + events, + listener: { + state: (live) => events.push(['state', live]), + newMail: (notice) => events.push(['new-mail', notice]), + upstream: (connected) => events.push(['upstream', connected]), + ended: () => events.push(['ended']), + }, + }; +} + +/** 엔진 대역 — 연 스트림을 모아 두고, 상태 묻기는 테스트가 정한 값을 돌려준다 */ +function fakeEngine() { + const streams: FakeEventStream[] = []; + const openStream = (signal: AbortSignal): Promise => { + const stream = new FakeEventStream(signal); + streams.push(stream); + return Promise.resolve({ accountId: ACCOUNT, body: stream.body() }); + }; + const engine = { + open: jest.fn(openStream), + snapshot: jest.fn((timeoutMs?: number) => { + void timeoutMs; + return Promise.resolve(reading('s0', 2)); + }), + changes: jest.fn((from: WatchPosition) => { + void from; + return Promise.resolve(reading('s0', 2)); + }), + }; + return { + engine, + streams, + openStream, + /** 마지막으로 연 스트림 */ + latest: () => streams[streams.length - 1], + }; +} + +/** 로그 spy 가 받은 문장 */ +const messagesOf = (spy: jest.SpyInstance): string[] => + (spy.mock.calls as unknown[][]).map(([message]) => String(message)); + +async function advance(ms: number): Promise { + await jest.advanceTimersByTimeAsync(ms); + await settle(); +} + +describe('JmapMailWatch (실시간 알림의 엔진 연결)', () => { + let warnSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; + const watches: MailWatch[] = []; + + /** 간격의 흔들림 — 0.5 면 흔들리지 않는다 (테스트마다 바꿀 수 있다) */ + let random: () => number = () => 0.5; + + const start = async ( + engine: WatchEngine, + listener: MailWatchListener, + ): Promise => { + const watch = await JmapMailWatch.start( + engine, + listener, + 'a***@arasystem.test', + { random: () => random() }, + ); + watches.push(watch); + await settle(); + return watch; + }; + + beforeEach(() => { + random = () => 0.5; + // 스트림 대역은 promise 로 돈다 — 타이머만 손으로 돌린다 + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const watch of watches.splice(0)) watch.close(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('스트림을 연 뒤 지금 상태를 묻고(처음 연결의 남은 시간 안에서) 그것을 처음 상태로 돌려준다 — 그때는 아무것도 알리지 않는다', async () => { + const { engine } = fakeEngine(); + const { events, listener } = recorder(); + + const watch = await start(engine, listener); + + expect(watch.initial).toEqual({ state: 's0', inboxUnread: 2 }); + expect(engine.open).toHaveBeenCalledTimes(1); + expect(engine.snapshot).toHaveBeenCalledTimes(1); + const [timeoutMs] = engine.snapshot.mock.calls[0]; + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(WATCH_START_TIMEOUT_MS); + // 열기가 묻기보다 먼저다 — 연 뒤의 변화는 스트림에 쌓여 놓치지 않는다 + expect(engine.open.mock.invocationCallOrder[0]).toBeLessThan( + engine.snapshot.mock.invocationCallOrder[0], + ); + expect(events).toEqual([]); + }); + + it('처음 연결이 실패하면 그 실패를 던지고 연 스트림을 닫는다', async () => { + const { engine, streams } = fakeEngine(); + engine.snapshot.mockRejectedValueOnce( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + + await expect( + JmapMailWatch.start(engine, recorder().listener, 'a***'), + ).rejects.toMatchObject({ code: 'MAIL_001' }); + expect(streams[0].aborted).toBe(true); + }); + + it(`처음 연결이 ${WATCH_START_TIMEOUT_MS / 1000}초 안에 끝나지 않으면 MAIL_001 — 기다리던 스트림 열기도 닫는다`, async () => { + const { engine } = fakeEngine(); + let openSignal: AbortSignal | null = null; + engine.open.mockImplementationOnce( + (signal: AbortSignal) => + new Promise((_resolve, reject) => { + openSignal = signal; + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ); + + const started = JmapMailWatch.start(engine, recorder().listener, 'a***'); + const settled = started.catch((error: unknown) => error); + await advance(WATCH_START_TIMEOUT_MS - 1); + expect(openSignal).not.toBeNull(); + expect((openSignal as AbortSignal | null)?.aborted).toBe(false); + + await advance(1); + const error = (await settled) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ reason: 'watch-start-timeout' }); + expect((openSignal as AbortSignal | null)?.aborted).toBe(true); + expect(engine.snapshot).not.toHaveBeenCalled(); + }); + + it(`이 계정의 변화 신호는 ${WATCH_DEBOUNCE_MS}ms 모아 한 번만 묻는다 — 달라진 state, 그다음 new-mail`, async () => { + const { engine, latest } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + engine.changes.mockResolvedValueOnce(reading('s1', 3, NEW_MAIL)); + + latest().send(stateEvent()); + await advance(100); + latest().send(stateEvent() + stateEvent()); + await advance(WATCH_DEBOUNCE_MS - 101); + expect(engine.changes).not.toHaveBeenCalled(); + + await advance(1); + expect(engine.changes).toHaveBeenCalledTimes(1); + expect(engine.changes).toHaveBeenCalledWith(position('email-s0')); + expect(events).toEqual([ + ['state', { state: 's1', inboxUnread: 3 }], + ['new-mail', NEW_MAIL], + ]); + + // 다음 변화는 새 기준부터 묻는다 + latest().send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1')); + }); + + it('ping · 다른 계정의 변화 · 형식이 틀린 이벤트로는 묻지 않는다 — 상태가 그대로면 state 도 보내지 않는다', async () => { + const { engine, latest } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + + latest().send( + PING_EVENT + stateEvent('other') + 'event: state\ndata: {}\n\n', + ); + await advance(WATCH_DEBOUNCE_MS * 2); + expect(engine.changes).not.toHaveBeenCalled(); + + latest().send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(1); + // 묻긴 했지만 처음 상태와 같다 + expect(events).toEqual([]); + }); + + it('묻는 동안 온 신호는 그 묻기가 끝난 뒤 한 번 더 묻는다 — 한 번에 하나', async () => { + const { engine, latest } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + + let answer: (value: WatchReading) => void = () => undefined; + engine.changes.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + engine.changes.mockResolvedValueOnce(reading('s2', 1)); + + latest().send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(1); + + latest().send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS * 3); + expect(engine.changes).toHaveBeenCalledTimes(1); + + answer(reading('s1', 2)); + await settle(); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1')); + expect(events).toEqual([ + ['state', { state: 's1', inboxUnread: 2 }], + ['state', { state: 's2', inboxUnread: 1 }], + ]); + }); + + it(`엔진에서 ${WATCH_SILENCE_MS / 1000}초 동안 아무것도 오지 않으면 끊긴 것으로 보고 upstream: false — 1초 뒤 다시 이어 지난 기준부터 묻고, 달라졌으면 state · new-mail`, async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + + // 받은 것이 있으면 그때부터 다시 잰다 + await advance(WATCH_SILENCE_MS - 5_000); + streams[0].send(PING_EVENT); + await advance(WATCH_SILENCE_MS - 1); + expect(events).toEqual([]); + expect(streams[0].aborted).toBe(false); + + await advance(1); + expect(streams[0].aborted).toBe(true); + expect(events).toEqual([['upstream', false]]); + + engine.changes.mockResolvedValueOnce(reading('s1', 4, NEW_MAIL)); + await advance(999); + expect(engine.open).toHaveBeenCalledTimes(1); + await advance(1); + expect(engine.open).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenCalledWith(position('email-s0')); + expect(events).toEqual([ + ['upstream', false], + ['upstream', true], + ['state', { state: 's1', inboxUnread: 4 }], + ['new-mail', NEW_MAIL], + ]); + // 잠깐의 끊김은 로그에 남기지 않는다 + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('다시 이었을 때 상태가 그대로면 upstream: true 만 알린다 — 엔진이 스트림을 닫아도 다시 잇는다', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + + streams[0].end(); + await settle(); + expect(events).toEqual([['upstream', false]]); + + await advance(1_000); + expect(engine.open).toHaveBeenCalledTimes(2); + expect(events).toEqual([ + ['upstream', false], + ['upstream', true], + ]); + }); + + it('다시 잇기가 이어서 실패하면 1 · 2 · 5 · 10 · 30 · 30초 간격 — 세 번 이어서 실패하면 warn 한 번(가린 주소), 다시 이으면 info 한 번', async () => { + const { engine, streams, openStream } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + // 엔진에서 무엇이든 받은(건강한) 연결이 끊긴다 + streams[0].send(PING_EVENT); + await settle(); + engine.open.mockRejectedValue( + new BusinessException('MAIL_001', { context: { reason: 'network' } }), + ); + streams[0].end(); + await settle(); + + const openedAfter = async (ms: number) => { + await advance(ms); + return engine.open.mock.calls.length; + }; + expect(await openedAfter(999)).toBe(1); + expect(await openedAfter(1)).toBe(2); + expect(await openedAfter(1_999)).toBe(2); + expect(await openedAfter(1)).toBe(3); + expect(await openedAfter(4_999)).toBe(3); + expect(warnSpy).not.toHaveBeenCalled(); + expect(await openedAfter(1)).toBe(4); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(messagesOf(warnSpy)[0]).toContain('user=a***@arasystem.test'); + expect(messagesOf(warnSpy)[0]).toContain('원인=MAIL_001/network'); + expect(await openedAfter(9_999)).toBe(4); + expect(await openedAfter(1)).toBe(5); + expect(await openedAfter(29_999)).toBe(5); + expect(await openedAfter(1)).toBe(6); + expect(await openedAfter(30_000)).toBe(7); + expect(warnSpy).toHaveBeenCalledTimes(1); + + engine.open.mockImplementation(openStream); + expect(await openedAfter(30_000)).toBe(8); + expect(events).toEqual([ + ['upstream', false], + ['upstream', true], + ]); + // 붙기만 해서는 회복으로 적지 않는다 — 엔진에서 이벤트를 받아야(정말로 이어져야) info 한 번 + expect(logSpy).not.toHaveBeenCalled(); + streams[streams.length - 1].send(PING_EVENT); + await settle(); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(messagesOf(logSpy)[0]).toContain('다시 이었습니다'); + }); + + it('다시 이은 연결이 아무것도 받지 못하고 또 끊기면 간격을 처음부터 세지 않는다 (끊기고 붙기를 되풀이하는 엔진을 1초마다 두드리지 않게)', async () => { + const { engine, streams } = fakeEngine(); + await start(engine, recorder().listener); + + streams[0].end(); + await advance(1_000); + expect(engine.open).toHaveBeenCalledTimes(2); + + streams[1].end(); + await advance(1_999); + expect(engine.open).toHaveBeenCalledTimes(2); + await advance(1); + expect(engine.open).toHaveBeenCalledTimes(3); + + // 이번에는 받은 것이 있다 — 다음 끊김은 다시 1초부터 + streams[2].send(PING_EVENT); + await settle(); + streams[2].end(); + await advance(1_000); + expect(engine.open).toHaveBeenCalledTimes(4); + }); + + it('변화를 묻다 실패하면 끊긴 것과 같게 다룬다 — upstream: false, 다시 이으며 지난 기준부터 다시 묻는다', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + engine.changes + .mockRejectedValueOnce( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ) + .mockResolvedValueOnce(reading('s1', 5)); + + streams[0].send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(streams[0].aborted).toBe(true); + expect(events).toEqual([['upstream', false]]); + + await advance(1_000); + expect(engine.changes).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s0')); + expect(events).toEqual([ + ['upstream', false], + ['upstream', true], + ['state', { state: 's1', inboxUnread: 5 }], + ]); + }); + + it('계정을 더 쓸 수 없으면(AUTH_001) 다시 잇지 않고 ended 를 알린다', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + engine.open.mockRejectedValue( + new BusinessException('AUTH_001', { + context: { reason: 'impersonation-rejected' }, + }), + ); + + streams[0].end(); + await advance(1_000); + await advance(120_000); + + expect(engine.open).toHaveBeenCalledTimes(2); + expect(events).toEqual([['upstream', false], ['ended']]); + }); + + it('닫으면 스트림을 닫고 그 뒤로는 아무것도 알리거나 묻지 않는다', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + const watch = await start(engine, listener); + + streams[0].send(stateEvent()); + watch.close(); + await advance(WATCH_SILENCE_MS * 2); + + expect(streams[0].aborted).toBe(true); + expect(engine.changes).not.toHaveBeenCalled(); + expect(engine.open).toHaveBeenCalledTimes(1); + expect(events).toEqual([]); + }); + + it('다시 잇는 간격은 ±20% 흔들린다 — 엔진이 재시작해도 모든 직원이 같은 순간에 다시 붙지 않게', async () => { + const { engine, latest } = fakeEngine(); + await start(engine, recorder().listener); + + // 가장 짧게 — 1초의 0.8배 + random = () => 0; + latest().end(); + await advance(799); + expect(engine.open).toHaveBeenCalledTimes(1); + await advance(1); + expect(engine.open).toHaveBeenCalledTimes(2); + + // 가장 길게 — 2초의 1.2배 가까이 (아무것도 받지 못한 연결이라 간격이 이어서 는다) + random = () => 0.999; + latest().end(); + await advance(2_398); + expect(engine.open).toHaveBeenCalledTimes(2); + await advance(1); + expect(engine.open).toHaveBeenCalledTimes(3); + }); + + it('엔진 한도(429 — REQ_001)는 끊김이 아니다 — 스트림 · upstream 은 그대로 두고 5초 뒤 다시 묻는다 (그사이 온 신호는 거기에 합친다)', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + engine.changes + .mockRejectedValueOnce( + new BusinessException('REQ_001', { + context: { reason: 'mail-engine-rate-limit' }, + }), + ) + .mockResolvedValueOnce(reading('s1', 3)); + + streams[0].send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(1); + expect(streams[0].aborted).toBe(false); + + streams[0].send(stateEvent()); + await advance(WATCH_RATE_LIMIT_RETRY_MS - 1); + expect(engine.changes).toHaveBeenCalledTimes(1); + await advance(1); + expect(engine.changes).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s0')); + expect(events).toEqual([['state', { state: 's1', inboxUnread: 3 }]]); + expect(engine.open).toHaveBeenCalledTimes(1); + }); + + it(`같은 기준으로 이어서 ${WATCH_BASE_FAILURES_BEFORE_RESET}번 묻지 못하면 그 기준을 버리고 처음 상태로 다시 잡는다 — 새 메일 없이, 끝없이 끊고 다시 잇지 않게`, async () => { + const { engine, streams, latest } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + engine.changes.mockRejectedValue( + new BusinessException('MAIL_001', { + context: { reason: 'jmap-method-error' }, + }), + ); + engine.snapshot.mockResolvedValueOnce(reading('s9', 1)); + + streams[0].send(PING_EVENT + stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(events).toEqual([['upstream', false]]); + // 스트림은 열리는데 묻기가 같은 기준에서 또 실패한다 — 1초 · 2초 뒤 + await advance(1_000); + await advance(2_000); + expect(engine.changes).toHaveBeenCalledTimes(3); + expect(engine.snapshot).toHaveBeenCalledTimes(1); + + await advance(5_000); + expect(engine.changes).toHaveBeenCalledTimes(3); + expect(engine.snapshot).toHaveBeenCalledTimes(2); + // 다시 잡을 때는 기본 제한 시간 (처음 연결의 남은 시간이 아니다) + expect(engine.snapshot).toHaveBeenLastCalledWith(); + expect(events).toEqual([ + ['upstream', false], + ['upstream', true], + ['state', { state: 's9', inboxUnread: 1 }], + ]); + + // 그 뒤로는 새 기준부터 묻는다 + engine.changes.mockResolvedValueOnce(reading('s9', 1)); + latest().send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s9')); + }); + + it(`신호가 없어도 마지막으로 물은 지 ${WATCH_SAFETY_REFRESH_MS / 60_000}분이 지나면 한 번 묻는다 (엔진이 신호를 빠뜨린 경우) — 그사이 물었으면 그때부터 다시 잰다`, async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + /** 엔진의 ping 을 30초마다 흉내 내며 시간을 보낸다 (무소식 감시에 걸리지 않게) */ + const keepAlive = async (ms: number) => { + for (let left = ms; left > 0; left -= 30_000) { + streams[0].send(PING_EVENT); + await advance(Math.min(30_000, left)); + } + }; + engine.changes.mockResolvedValueOnce(reading('s1', 3, NEW_MAIL)); + + await keepAlive(WATCH_SAFETY_REFRESH_MS - 1); + expect(engine.changes).not.toHaveBeenCalled(); + await advance(1 + WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + ['state', { state: 's1', inboxUnread: 3 }], + ['new-mail', NEW_MAIL], + ]); + + // 2분 뒤 변화 신호로 물었다 — 다음 안전판은 그때부터 5분 뒤 + engine.changes.mockResolvedValue(reading('s1', 3)); + await keepAlive(120_000); + streams[0].send(stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(2); + await keepAlive(WATCH_SAFETY_REFRESH_MS - 1); + expect(engine.changes).toHaveBeenCalledTimes(2); + await advance(1 + WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(3); + }); + + it('붙자마자 다시 끊기기를 되풀이하는 엔진 — warn 은 한 번만, 회복 info 는 엔진에서 이벤트를 받을 때까지 남기지 않는다', async () => { + const { engine, latest } = fakeEngine(); + await start(engine, recorder().listener); + + // 매번 열리기는 하지만 아무것도 보내지 않고 닫힌다 + for (let round = 0; round < 6; round += 1) { + latest().end(); + await advance(30_000); + } + expect(engine.open).toHaveBeenCalledTimes(7); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalled(); + + latest().send(PING_EVENT); + await settle(); + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('끊기기 전에 시작한 묻기가 있으면 다시 잇기는 그것이 끝난 뒤 새 기준부터 묻는다 — 새 메일을 두 번 알리지 않는다', async () => { + const { engine, streams } = fakeEngine(); + const { events, listener } = recorder(); + await start(engine, listener); + let answer: (value: WatchReading) => void = () => undefined; + engine.changes + .mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }), + ) + .mockResolvedValueOnce(reading('s1', 3)); + + streams[0].send(PING_EVENT + stateEvent()); + await advance(WATCH_DEBOUNCE_MS); + expect(engine.changes).toHaveBeenCalledTimes(1); + + // 묻는 사이 엔진이 스트림을 닫는다 — 다시 이을 때(1초)가 지나도 묻기가 끝나기를 기다린다 + streams[0].end(); + await advance(5_000); + expect(events).toEqual([['upstream', false]]); + expect(engine.open).toHaveBeenCalledTimes(1); + + answer(reading('s1', 3, NEW_MAIL)); + await settle(); + expect(engine.open).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenCalledTimes(2); + expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1')); + expect(events).toEqual([ + ['upstream', false], + ['state', { state: 's1', inboxUnread: 3 }], + ['new-mail', NEW_MAIL], + ['upstream', true], + ]); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-mail-watch.ts b/backend/src/modules/mail/jmap/jmap-mail-watch.ts new file mode 100644 index 0000000..cdeb701 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-mail-watch.ts @@ -0,0 +1,541 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + MailLiveState, + MailWatch, + MailWatchListener, + NewMailNotice, +} from '@/modules/mail/mail.types'; +import { SseParser } from '@/shared/utils/sse'; +import type { JmapEventStream } from './jmap.client'; +import { PUSH_STATE_EVENT, isMailStateChange } from './jmap-push'; + +/** + * 실시간 알림의 메일 엔진 연결 하나 (계약 2절 "실시간 알림") — 직원 한 명에 하나, 그 직원의 모든 클라이언트 연결이 나눠 쓴다(MailEventsService). + * + * - 엔진의 EventSource 를 열고 지금 상태를 물은 뒤 읽기 시작한다 (연 뒤에 물으므로 그사이의 변화도 놓치지 않는다 — 소식은 스트림에 쌓인다) + * - 이 계정의 메일함 · 메일 변화 신호는 WATCH_DEBOUNCE_MS 동안 모아 한 번만 묻는다 (한 번에 하나, 묻는 동안 온 신호는 끝난 뒤 다시) + * - 신호가 없어도 마지막으로 물은 지 WATCH_SAFETY_REFRESH_MS 가 지나면 한 번 묻는다 (엔진이 신호를 빠뜨린 경우의 안전판) + * - 엔진에서 WATCH_SILENCE_MS 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다 (WATCH_RECONNECT_DELAYS_MS 간격 ± 흔들림) + * - 다시 이으면 지난 기준부터 다시 물어 달라진 상태 · 그동안의 새 메일을 알린다. 엔진에 묻기가 실패해도 끊긴 것과 같게 다룬다 — + * 다만 엔진 한도(REQ_001 — 429)는 끊긴 것이 아니다: 스트림은 그대로 두고 WATCH_RATE_LIMIT_RETRY_MS 뒤 다시 묻는다 + * - 같은 기준으로 이어서 WATCH_BASE_FAILURES_BEFORE_RESET 번 묻지 못하면 그 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이) + * - 계정을 더 쓸 수 없으면(AUTH_001 — 계정이 사라짐 · 막은 계정) 다시 잇지 않고 끝낸다 + * - 로그: 열고 닫기 · 한두 번의 끊김은 남기지 않는다 (반복되는 정상 흐름). 다시 잇기가 이어서 실패하면 warn(가린 주소) 한 번, + * 그 뒤 엔진에서 이벤트를 다시 받으면(정말로 이어졌을 때) info 한 번. 새 메일의 제목 · 보낸 사람은 어떤 레벨에도 남기지 않는다 + * + * 엔진 호출은 받은 함수(WatchEngine)로만 한다 — 호출 모양 · 새 메일 계산은 jmap-mail.provider.ts 에 있다. + */ + +/** + * 처음 연결(스트림을 열고 지금 상태를 묻기)의 상한 — 그동안 클라이언트는 응답 머리를 기다린다(앞단 nginx 는 90초 뒤 504). + * 다른 엔진 호출과 같은 10초 — 넘으면 MAIL_001 (스트림 · 진행 중인 묻기는 닫고 결과를 버린다) + */ +export const WATCH_START_TIMEOUT_MS = 10_000; +/** 이만큼 엔진에서 아무것도 오지 않으면 끊긴 것으로 본다 — 엔진의 ping(30초)을 두 번 놓친 뒤 */ +export const WATCH_SILENCE_MS = 75_000; +/** 다시 잇는 간격 — 이어서 실패할수록 길게, 끝 값을 계속 쓴다. 엔진에서 무엇이든 받은 연결이 생기면 처음부터 다시 센다 */ +export const WATCH_RECONNECT_DELAYS_MS: readonly number[] = [ + 1_000, 2_000, 5_000, 10_000, 30_000, +]; +/** + * 다시 잇기 · 다시 묻기 간격의 흔들림 (±20%) — 엔진이 재시작하면 모든 직원의 연결이 함께 끊기므로, + * 흔들지 않으면 모두 같은 순간에 다시 붙는다 + */ +export const WATCH_RETRY_JITTER = 0.2; +/** 엔진 신호를 모으는 시간 — 한 번의 변경(배달 · 옮기기)에도 신호가 여럿 온다 */ +export const WATCH_DEBOUNCE_MS = 300; +/** 다시 잇기가 이만큼 이어서 실패하면 warn 을 남긴다 — 엔진 재시작(몇 초) 같은 잠깐의 끊김은 남기지 않는다 */ +export const WATCH_WARN_AFTER_ATTEMPTS = 3; +/** 엔진 한도(429)에 걸린 묻기를 다시 하는 간격 (± 흔들림) — 묻기만 막혔으니 스트림은 그대로 둔다 */ +export const WATCH_RATE_LIMIT_RETRY_MS = 5_000; +/** + * 같은 기준으로 이어서 이만큼 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이) — + * 그 기준에서 늘 나는 오류(cannotCalculateChanges · 요청 거부 밖의 메서드 오류 등)로 끊고 다시 잇기를 끝없이 하지 않게 + */ +export const WATCH_BASE_FAILURES_BEFORE_RESET = 3; +/** + * 신호 없이도 마지막으로 물은 지 이만큼 지나면 한 번 묻는다 — 엔진이 신호를 빠뜨려도 낡은 값이 오래 남지 않게. + * 연결이 살아 있는 동안 클라이언트는 1분 확인을 멈추므로 이것이 그 대신이다 + */ +export const WATCH_SAFETY_REFRESH_MS = 5 * 60_000; + +/** 새 메일 계산의 기준 — 어댑터 안에서만 쓴다 (엔진 상태 문자열이 들어 있다) */ +export interface WatchPosition { + /** Email/changes 를 시작할 엔진의 메일 상태 */ + readonly emailState: string; + /** 받은편지함 id — 없으면 null (안 읽은 수 0, 새 메일 없음) */ + readonly inboxId: string | null; + /** 본인 주소(소문자) — 직원 주소와 발신 ID 주소 */ + readonly ownAddresses: ReadonlySet; +} + +/** 엔진에 물은 결과 — 지금 상태 · 다음 기준 · 새 메일(셀 수 없거나 없으면 null) */ +export interface WatchReading { + readonly live: MailLiveState; + readonly position: WatchPosition; + readonly newMail: NewMailNotice | null; +} + +/** 엔진 호출 — 어댑터(JmapMailProvider.watchMail)가 직원 주소를 묶어 넘긴다 */ +export interface WatchEngine { + /** 변화 소식 스트림을 연다 (응답 머리까지 받으면 돌려준다) — `signal` 로 닫는다 */ + open(signal: AbortSignal): Promise; + /** 지금 상태와 기준을 처음 잡는다 (새 메일은 없다) — `timeoutMs` 안에 (세션 조회 포함, 없으면 기본 제한 시간) */ + snapshot(timeoutMs?: number): Promise; + /** 기준부터 바뀐 것 — 지금 상태 · 다음 기준 · 새 메일 */ + changes(position: WatchPosition): Promise; +} + +export interface WatchOptions { + /** [0, 1) 난수 — 간격의 흔들림에 쓴다 (테스트가 정해 넣는다) */ + readonly random?: () => number; +} + +/** 엔진 연결 한 번 (스트림 하나) */ +interface Connection { + readonly controller: AbortController; + /** 엔진에서 이벤트(ping 포함)를 하나라도 받았다 — 받은 뒤로는 다시 잇기 간격을 처음부터 센다 */ + healthy: boolean; + /** 우리가 끊었으면 그 까닭 (무소식 · 엔진에 묻기 실패) */ + reason: string | null; +} + +/** 다시 잇지 않을 실패 — 계정을 더 쓸 수 없다 (사라진 계정 · 막은 계정 — jmap.client.ts 의 AUTH_001) */ +function isAccountUnusable(error: unknown): boolean { + return error instanceof BusinessException && error.code === 'AUTH_001'; +} + +/** 엔진 한도(HTTP 429 — jmap.client.ts 의 REQ_001) — 끊긴 것이 아니라 묻기만 막혔다 */ +function isRateLimited(error: unknown): boolean { + return error instanceof BusinessException && error.code === 'REQ_001'; +} + +/** + * 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다). + * 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김) + */ +function describeFailure(error: unknown): string { + if (error instanceof BusinessException) { + const reason: unknown = error.context?.reason; + return typeof reason === 'string' ? `${error.code}/${reason}` : error.code; + } + if (!(error instanceof Error)) return 'unknown'; + const { cause } = error as { cause?: unknown }; + const causeCode = + cause instanceof Error + ? ((cause as NodeJS.ErrnoException).code ?? cause.name) + : null; + return causeCode ? `${error.name}/${causeCode}` : error.name; +} + +/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */ +function timer(callback: () => void, ms: number): NodeJS.Timeout { + const handle = setTimeout(callback, ms); + handle.unref(); + return handle; +} + +export class JmapMailWatch { + private readonly logger = new Logger(JmapMailWatch.name); + private readonly random: () => number; + private closed = false; + /** 엔진과 이어져 있다 — 끊긴 뒤 다시 이을 때까지 false */ + private connected = false; + private connection: Connection | null = null; + private position!: WatchPosition; + private latest!: MailLiveState; + private silenceTimer: NodeJS.Timeout | null = null; + /** 다음 묻기 — 신호를 모으는 중이거나 엔진 한도로 미룬 묻기 */ + private askTimer: NodeJS.Timeout | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private safetyTimer: NodeJS.Timeout | null = null; + /** 진행 중인 엔진 묻기 — 한 번에 하나 (다시 잇기는 이것이 끝난 뒤 묻는다) */ + private refreshing: Promise | null = null; + /** 묻는 동안 신호가 왔다 — 끝나면 한 번 더 */ + private signalledWhileRefreshing = false; + /** 마지막으로 건강했던 연결 뒤로 다시 이은 횟수 */ + private attempts = 0; + /** 같은 기준으로 이어서 묻지 못한 횟수 — 엔진 한도(429)와 스트림을 열지 못한 것은 세지 않는다 */ + private failuresAtBase = 0; + /** 이번 끊김에 warn 을 남겼다 — 다시 건강한 연결이 생길 때까지 다시 남기지 않는다 */ + private warned = false; + + private constructor( + private readonly engine: WatchEngine, + private readonly listener: MailWatchListener, + /** 로그에 남기는 직원 — 가린 주소 */ + private readonly label: string, + options: WatchOptions, + ) { + this.random = options.random ?? Math.random; + } + + /** + * 엔진 연결을 열고 처음 상태를 잡는다 — 실패는 그 코드로 던지고 아무것도 남기지 않는다. + * @param label 로그용 직원 표시 (가린 주소) + */ + static async start( + engine: WatchEngine, + listener: MailWatchListener, + label: string, + options: WatchOptions = {}, + ): Promise { + const watch = new JmapMailWatch(engine, listener, label, options); + const initial = await watch.connectFirst(); + return { initial, close: () => watch.close() }; + } + + /** + * 처음 연결 — WATCH_START_TIMEOUT_MS 안에 스트림을 열고 지금 상태를 물어야 한다(넘으면 MAIL_001). + * 넘거나 실패하면 닫는다 — 늦게 끝난 열기 · 묻기는 닫힌 연결이라 버려진다(스트림은 abort 로 닫힌다) + */ + private async connectFirst(): Promise { + const connection = this.begin(); + const deadline = Date.now() + WATCH_START_TIMEOUT_MS; + let expiry: NodeJS.Timeout | null = null; + const timedOut = new Promise((_resolve, reject) => { + expiry = timer( + () => + reject( + new BusinessException('MAIL_001', { + context: { reason: 'watch-start-timeout' }, + }), + ), + WATCH_START_TIMEOUT_MS, + ); + }); + + const work = (async () => { + const stream = await this.engine.open(connection.controller.signal); + // 그사이 시간이 다 됐으면 묻지 않는다 (스트림은 close 의 abort 로 이미 닫혔다) + if (this.closed) throw new Error('watch closed'); + const reading = await this.engine.snapshot( + Math.max(1, deadline - Date.now()), + ); + return { stream, reading }; + })(); + + try { + const { stream, reading } = await Promise.race([work, timedOut]); + this.position = reading.position; + this.latest = reading.live; + this.connected = true; + this.armSafety(); + this.consume(connection, stream); + return reading.live; + } catch (error) { + this.close(); + throw error; + } finally { + this.clearTimer(expiry); + } + } + + /** 닫는다 — 이 뒤로는 listener 를 부르지 않는다 */ + close(): void { + if (this.closed) return; + this.closed = true; + this.connected = false; + this.clearTimers(); + this.connection?.controller.abort(); + this.connection = null; + } + + private begin(): Connection { + const connection: Connection = { + controller: new AbortController(), + healthy: false, + reason: null, + }; + this.connection = connection; + return connection; + } + + /** 스트림을 읽기 시작한다 — 무소식 시간은 지금부터 잰다 */ + private consume(connection: Connection, stream: JmapEventStream): void { + this.armSilence(connection); + void this.readStream(connection, stream); + } + + private async readStream( + connection: Connection, + stream: JmapEventStream, + ): Promise { + const decoder = new TextDecoder(); + const parser = new SseParser(); + let reason = 'stream-ended'; + try { + for await (const chunk of stream.body) { + if (connection !== this.connection) return; + this.armSilence(connection); + for (const message of parser.push( + decoder.decode(chunk, { stream: true }), + )) { + if (!connection.healthy) this.becameHealthy(connection); + if ( + message.event === PUSH_STATE_EVENT && + isMailStateChange(message.data, stream.accountId) + ) { + this.signal(); + } + } + } + } catch (error) { + reason = describeFailure(error); + } + this.lost(connection, connection.reason ?? reason); + } + + /** + * 엔진에서 이벤트를 처음 받았다 — 정말로 이어졌다. 다시 잇기 간격을 처음부터 센다. + * 끊김을 warn 으로 남겼으면 여기서 info 한 번 (붙자마자 다시 끊기는 엔진을 회복으로 적지 않게) + */ + private becameHealthy(connection: Connection): void { + connection.healthy = true; + this.attempts = 0; + if (this.warned) { + this.warned = false; + this.logger.log( + `메일 엔진 실시간 연결을 다시 이었습니다: user=${this.label}`, + ); + } + } + + /** 우리가 연결을 끊는다 — 읽던 쪽이 lost 로 이어 받는다 */ + private abort(connection: Connection, reason: string): void { + connection.reason ??= reason; + connection.controller.abort(); + } + + private armSilence(connection: Connection): void { + this.clearTimer(this.silenceTimer); + this.silenceTimer = timer( + () => this.abort(connection, 'silence'), + WATCH_SILENCE_MS, + ); + } + + /** 마지막으로 물은 뒤 WATCH_SAFETY_REFRESH_MS 가 지나면 신호 없이 한 번 묻는다 */ + private armSafety(): void { + this.clearTimer(this.safetyTimer); + this.safetyTimer = timer(() => { + this.safetyTimer = null; + this.signal(); + }, WATCH_SAFETY_REFRESH_MS); + } + + /** 흔든 간격 — `ms` 의 ±WATCH_RETRY_JITTER */ + private jittered(ms: number): number { + return Math.round(ms * (1 + WATCH_RETRY_JITTER * (2 * this.random() - 1))); + } + + /** 엔진 연결이 끊겼다 — 클라이언트에 알리고 다시 잇는다 */ + private lost(connection: Connection, reason: string): void { + if (this.closed || connection !== this.connection) return; + this.connection = null; + connection.controller.abort(); + this.clearTimer(this.silenceTimer); + this.clearTimer(this.askTimer); + this.clearTimer(this.safetyTimer); + this.silenceTimer = null; + this.askTimer = null; + this.safetyTimer = null; + this.signalledWhileRefreshing = false; + if (this.connected) { + this.connected = false; + this.notify(() => this.listener.upstream(false)); + } + this.scheduleReconnect(reason); + } + + private scheduleReconnect(reason: string): void { + if (this.attempts >= WATCH_WARN_AFTER_ATTEMPTS && !this.warned) { + this.warned = true; + this.logger.warn( + `[MAIL_001] 메일 엔진 실시간 연결이 이어서 실패합니다 — 클라이언트는 1분 확인으로 돌아가 있고, 다시 잇는 중입니다: user=${this.label}, 이어서=${this.attempts}번, 원인=${reason}`, + ); + } + const delay = this.jittered( + WATCH_RECONNECT_DELAYS_MS[ + Math.min(this.attempts, WATCH_RECONNECT_DELAYS_MS.length - 1) + ], + ); + this.attempts += 1; + this.retryTimer = timer(() => { + this.retryTimer = null; + void this.reconnect(); + }, delay); + } + + /** + * 엔진에 묻는다 — 지난 기준부터 바뀐 것. 같은 기준으로 이어서 WATCH_BASE_FAILURES_BEFORE_RESET 번 묻지 못했으면 + * 그 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이 — 기준이 망가졌으면 셀 수 없다) + */ + private ask(): Promise { + return this.failuresAtBase >= WATCH_BASE_FAILURES_BEFORE_RESET + ? this.engine.snapshot() + : this.engine.changes(this.position); + } + + /** 물은 결과를 받았다 — 기준을 옮기고 달라진 것을 알린다 */ + private accept(reading: WatchReading): void { + this.failuresAtBase = 0; + this.position = reading.position; + this.emit(reading); + if (this.connected) this.armSafety(); + } + + private async reconnect(): Promise { + // 끊기기 전에 시작한 묻기가 있으면 끝난 뒤에 — 같은 기준으로 두 번 물어 새 메일을 두 번 알리지 않게 + if (this.refreshing) await this.refreshing; + if (this.closed) return; + + const connection = this.begin(); + let stream: JmapEventStream; + try { + stream = await this.engine.open(connection.controller.signal); + } catch (error) { + this.failedReconnect(connection, error); + return; + } + + let reading: WatchReading; + try { + reading = await this.ask(); + } catch (error) { + // 스트림은 열렸는데 묻지 못했다 — 엔진 한도가 아니면 이 기준으로 실패한 것으로 센다 + if (!isRateLimited(error)) this.failuresAtBase += 1; + this.failedReconnect(connection, error); + return; + } + if (this.closed || connection !== this.connection) { + connection.controller.abort(); + return; + } + + this.connected = true; + this.notify(() => this.listener.upstream(true)); + this.accept(reading); + this.consume(connection, stream); + } + + private failedReconnect(connection: Connection, error: unknown): void { + if (this.closed || connection !== this.connection) return; + this.connection = null; + connection.controller.abort(); + if (isAccountUnusable(error)) { + this.end(); + return; + } + this.scheduleReconnect(describeFailure(error)); + } + + /** 이 계정의 변화 신호(또는 안전판) — 모아서 한 번 묻는다 */ + private signal(): void { + if (this.closed || !this.connected) return; + if (this.refreshing) { + this.signalledWhileRefreshing = true; + return; + } + if (this.askTimer) return; + this.scheduleAsk(WATCH_DEBOUNCE_MS); + } + + private scheduleAsk(delay: number): void { + this.clearTimer(this.askTimer); + this.askTimer = timer(() => { + this.askTimer = null; + this.refresh(); + }, delay); + } + + private refresh(): void { + const connection = this.connection; + if (this.closed || !this.connected || !connection || this.refreshing) { + return; + } + this.refreshing = this.ask() + .then( + (reading) => { + if (this.closed) return; + this.accept(reading); + }, + (error: unknown) => { + if (this.closed) return; + if (isAccountUnusable(error)) { + this.end(); + return; + } + if (isRateLimited(error)) { + // 엔진 한도 — 끊긴 것이 아니다. 스트림은 두고 잠시 뒤 다시 묻는다 (upstream 은 그대로) + if (this.connected) { + this.scheduleAsk(this.jittered(WATCH_RATE_LIMIT_RETRY_MS)); + } + return; + } + // 엔진에 묻지 못했다 — 끊긴 것과 같게 다룬다 (다시 이으며 지난 기준부터 다시 묻는다) + this.failuresAtBase += 1; + this.abort(connection, describeFailure(error)); + }, + ) + .finally(() => { + this.refreshing = null; + if (this.signalledWhileRefreshing) { + this.signalledWhileRefreshing = false; + this.signal(); + } + }); + } + + /** 달라진 상태 → state, 새 메일 → new-mail (같은 변화의 state 다음) */ + private emit(reading: WatchReading): void { + const { live, newMail } = reading; + if ( + live.state !== this.latest.state || + live.inboxUnread !== this.latest.inboxUnread + ) { + this.latest = live; + this.notify(() => this.listener.state(live)); + } + if (newMail) this.notify(() => this.listener.newMail(newMail)); + } + + /** 계정을 더 쓸 수 없다 — 닫고 알린다 */ + private end(): void { + this.close(); + this.notify(() => this.listener.ended()); + } + + /** listener 의 실수가 엔진 연결의 흐름을 끊지 않게 */ + private notify(call: () => void): void { + try { + call(); + } catch (error) { + this.logger.error( + `[SYS_001] 실시간 알림을 전하지 못했습니다: user=${this.label}, 원인=${describeFailure(error)}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + private clearTimers(): void { + for (const handle of [ + this.silenceTimer, + this.askTimer, + this.retryTimer, + this.safetyTimer, + ]) { + this.clearTimer(handle); + } + this.silenceTimer = null; + this.askTimer = null; + this.retryTimer = null; + this.safetyTimer = null; + } + + private clearTimer(handle: NodeJS.Timeout | null): void { + if (handle) clearTimeout(handle); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index bd03406..8511031 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -8,13 +8,24 @@ import { Readable } from 'stream'; import { BusinessException } from '@/common/errors/business.exception'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; import type { MailSettings } from '@/modules/mail/mail.settings'; -import type { DraftContent, OutgoingMessage } from '@/modules/mail/mail.types'; +import type { + DraftContent, + MailWatch, + MailWatchListener, + OutgoingMessage, +} from '@/modules/mail/mail.types'; +import { + FakeEventStream, + settleAsync, + stateChangeEvent, +} from '@/../test/fake-event-stream'; import { JMAP_INLINE_UPLOAD_TIMEOUT_MS, JMAP_SEND_TIMEOUT_MS, } from './jmap.client'; import type { JmapClient, JmapSession } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch'; import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; import { MAILBOX_PROPERTIES } from './jmap.mapper'; import type { JmapInvocation } from './jmap.types'; @@ -143,6 +154,7 @@ function fakeClient( upload: jest.fn(), download: jest.fn(), downloadBytes: jest.fn(), + openEventSource: jest.fn(), }; const store = new Map(); const cache = { @@ -1337,6 +1349,422 @@ describe('JmapMailProvider', () => { }); }); + describe('실시간 알림 (watchMail)', () => { + /** 처음 상태 응답 — 모든 메일함 · 메일 상태 · 발신 ID */ + const snapshotAnswer = ( + mailboxState = 'sb2', + emailState = 'saj', + ): JmapInvocation[] => [ + [ + 'Mailbox/get', + { state: mailboxState, list: stalwartMailboxes, notFound: [] }, + 'm', + ], + ['Email/get', { state: emailState, list: [], notFound: [] }, 'e'], + [ + 'Identity/get', + { + list: [ + { id: 'i1', email: ARA }, + { id: 'i2', email: 'Ara@Alias.ArasYstem.test' }, + ], + }, + 'i', + ], + ]; + + /** 변화 응답 — Email/changes · 생긴 메일 · 받은편지함 · 메일 상태 */ + const changesAnswer = ( + changes: JmapInvocation, + created: unknown[], + mailboxState: string, + emailState: string, + inboxUnread = 3, + ): JmapInvocation[] => [ + changes, + ['Email/get', { state: emailState, list: created, notFound: [] }, 'n'], + [ + 'Mailbox/get', + { + state: mailboxState, + list: [{ id: 'a', role: 'inbox', unreadEmails: inboxUnread }], + notFound: [], + }, + 'm', + ], + ['Email/get', { state: emailState, list: [], notFound: [] }, 'e'], + ]; + + const changed = ( + newState: string, + created: string[], + hasMoreChanges = false, + ): JmapInvocation => [ + 'Email/changes', + { + oldState: 'old', + newState, + hasMoreChanges, + created, + updated: [], + destroyed: [], + }, + 'c', + ]; + + const delivered = (id: string, extra: Record = {}) => ({ + id, + mailboxIds: { a: true }, + keywords: {}, + from: [{ name: '이도윤', email: DOYUN }], + subject: `제목 ${id}`, + receivedAt: '2026-10-28T05:42:00Z', + ...extra, + }); + + type Event = [string, unknown]; + + /** 엔진 대역 위에서 연결을 열고, 알리는 것을 모은다 */ + async function watch(responses: JmapInvocation[][]) { + const fake = fakeClient(responses); + const streams: FakeEventStream[] = []; + fake.client.openEventSource.mockImplementation( + (_user: string, _options: unknown, signal: AbortSignal) => { + const stream = new FakeEventStream(signal); + streams.push(stream); + return Promise.resolve({ accountId: 'd', body: stream.body() }); + }, + ); + const events: Event[] = []; + const listener: MailWatchListener = { + state: (live) => events.push(['state', live]), + newMail: (notice) => events.push(['new-mail', notice]), + upstream: (connected) => events.push(['upstream', connected]), + ended: () => events.push(['ended', null]), + }; + const opened = await fake.provider.watchMail(ARA, listener); + opens.push(opened); + await settleAsync(); + return { ...fake, opened, streams, events }; + } + + /** 이 계정의 변화 신호를 보내고 모으는 시간이 지나기를 기다린다 */ + async function signal(stream: FakeEventStream): Promise { + stream.send(stateChangeEvent('d')); + await jest.advanceTimersByTimeAsync(WATCH_DEBOUNCE_MS); + await settleAsync(); + } + + const opens: MailWatch[] = []; + + beforeEach(() => { + // 변화를 보는 시각 — delivered() 의 받은 시각(05:42)은 그 3분 전이다 (10분 넘게 앞선 메일은 알리지 않는다) + jest.useFakeTimers({ + now: Date.parse('2026-10-28T05:45:00Z'), + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + }); + + afterEach(() => { + for (const opened of opens.splice(0)) opened.close(); + jest.useRealTimers(); + }); + + it('대리 접근 스트림(types=Mailbox,Email · ping 30)을 연 뒤 요청 한 번으로 처음 상태를 묻는다 — state 는 GET /api/mail-state 와 같은 값', async () => { + const { client, recorded, opened } = await watch([snapshotAnswer()]); + + expect(client.openEventSource).toHaveBeenCalledWith( + ARA, + { types: ['Mailbox', 'Email'], pingSeconds: 30 }, + expect.any(AbortSignal), + ); + expect(recorded).toHaveLength(1); + expect(recorded[0].calls).toEqual([ + [ + 'Mailbox/get', + { + accountId: 'd', + ids: null, + properties: ['id', 'role', 'unreadEmails'], + }, + 'm', + ], + ['Email/get', { accountId: 'd', ids: [] }, 'e'], + [ + 'Identity/get', + { accountId: 'd', ids: null, properties: ['email'] }, + 'i', + ], + ]); + // 처음 연결의 남은 시간 안에서 묻는다 (클라이언트가 응답 머리를 기다린다) + const { timeoutMs } = recorded[0].options as { timeoutMs: number }; + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(WATCH_START_TIMEOUT_MS); + + const polled = await fakeClient([ + [ + ['Mailbox/get', { state: 'sb2', list: [] }, 'm'], + ['Email/get', { state: 'saj', list: [] }, 'e'], + ], + ]).provider.getMailState(ARA); + expect(opened.initial).toEqual({ state: polled.state, inboxUnread: 2 }); + }); + + it('이 계정의 변화 신호 → 지난 메일 상태부터 Email/changes · 생긴 메일 · 받은편지함 · 메일 상태를 한 요청으로 — 새 메일만 골라 state 다음 new-mail', async () => { + const { recorded, streams, events } = await watch([ + snapshotAnswer(), + changesAnswer( + changed('sak', ['m1', 'm2', 'm3', 'm4']), + [ + delivered('m1'), + delivered('m2', { keywords: { $draft: true } }), + delivered('m3', { + from: [{ name: '나', email: 'ara@alias.arasystem.test' }], + }), + delivered('m4', { keywords: { $seen: true } }), + ], + 'sb3', + 'sal', + ), + changesAnswer(changed('sam', []), [], 'sb3', 'sam'), + ]); + + await signal(streams[0]); + + expect(recorded[1].calls).toEqual([ + [ + 'Email/changes', + { accountId: 'd', sinceState: 'saj', maxChanges: 500 }, + 'c', + ], + [ + 'Email/get', + { + accountId: 'd', + '#ids': { resultOf: 'c', name: 'Email/changes', path: '/created' }, + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'from', + 'subject', + 'receivedAt', + ], + }, + 'n', + ], + // 받은편지함은 처음에 찾아 두었다 + [ + 'Mailbox/get', + { + accountId: 'd', + ids: ['a'], + properties: ['id', 'role', 'unreadEmails'], + }, + 'm', + ], + ['Email/get', { accountId: 'd', ids: [] }, 'e'], + ]); + const polled = await fakeClient([ + [ + ['Mailbox/get', { state: 'sb3', list: [] }, 'm'], + ['Email/get', { state: 'sal', list: [] }, 'e'], + ], + ]).provider.getMailState(ARA); + expect(events).toEqual([ + ['state', { state: polled.state, inboxUnread: 3 }], + [ + 'new-mail', + { + count: 1, + messages: [ + { + id: 'm1', + from: { name: '이도윤', email: DOYUN }, + subject: '제목 m1', + receivedAt: '2026-10-28T05:42:00.000Z', + }, + ], + }, + ], + ]); + + // 다음 기준은 Email/changes 의 newState — 같은 요청에서 그 뒤에 생긴 메일을 다음에 센다 + await signal(streams[0]); + expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sak' }); + }); + + it('변화를 본 시각보다 10분 넘게 앞서 받은 메일은 알리지 않는다(IMAP 으로 올린 옛 메일 · imapsync) — state 는 그대로', async () => { + const { streams, events } = await watch([ + snapshotAnswer(), + changesAnswer( + changed('sak', ['old', 'fresh']), + [ + delivered('old', { receivedAt: '2019-05-01T09:00:00Z' }), + delivered('fresh'), + ], + 'sb3', + 'sal', + ), + ]); + + await signal(streams[0]); + + expect(events.map(([type]) => type)).toEqual(['state', 'new-mail']); + expect(events[1][1]).toMatchObject({ + count: 1, + messages: [{ id: 'fresh' }], + }); + }); + + it('셀 수 없으면(cannotCalculateChanges) new-mail 없이 state 만 — 다음 기준은 지금 메일 상태', async () => { + const { recorded, streams, events } = await watch([ + snapshotAnswer(), + changesAnswer( + ['error', { type: 'cannotCalculateChanges' }, 'c'], + [], + 'sb3', + 'sal', + ).map((invocation) => + invocation[2] === 'n' + ? ([ + 'error', + { type: 'invalidResultReference' }, + 'n', + ] as JmapInvocation) + : invocation, + ), + changesAnswer(changed('sam', []), [], 'sb3', 'sam'), + ]); + + await signal(streams[0]); + expect(events.map(([type]) => type)).toEqual(['state']); + + await signal(streams[0]); + expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sal' }); + }); + + it('엔진이 요청 전체를 거부하면(해석하지 못하는 옛 기준 — Stalwart 는 400 notRequest) 셀 수 없는 것과 같게: 처음 상태를 다시 잡아 새 기준으로, new-mail 없이', async () => { + const { client, recorded, streams, events } = await watch([ + snapshotAnswer(), + snapshotAnswer('sb9', 'fresh'), + changesAnswer(changed('fresh2', []), [], 'sb9', 'fresh2'), + ]); + client.call.mockImplementationOnce( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + // 요청 모양은 기록하고(준비한 응답은 쓰지 않는다), 엔진은 요청 전체를 거부한다 (jmap.client.ts 가 invalidRequestCode 로 던진다) + recorded.push({ calls: build(session.accountId), options }); + return Promise.reject( + new BusinessException('MAIL_001', { + context: { reason: 'request-rejected', jmapType: 'notRequest' }, + }), + ); + }, + ); + + await signal(streams[0]); + + // 거부된 요청은 거부를 가를 수 있게 invalidRequestCode 를 단다 + expect(recorded[1].calls[0][0]).toBe('Email/changes'); + expect(recorded[1].options).toEqual({ invalidRequestCode: 'MAIL_001' }); + // 처음 상태를 다시 잡는다 (모든 메일함 · 메일 상태 · 발신 ID) + expect(recorded[2].calls.map(([name]) => name)).toEqual([ + 'Mailbox/get', + 'Email/get', + 'Identity/get', + ]); + expect(events.map(([type]) => type)).toEqual(['state']); + expect(events).not.toContainEqual(['upstream', false]); + + await signal(streams[0]); + expect(argsOf(recorded[3], 0)).toMatchObject({ sinceState: 'fresh' }); + }); + + it('변화가 한 번에 다 오지 않으면(hasMoreChanges) newState 부터 이어 묻는다 — 4번을 넘으면 이번에는 new-mail 없이', async () => { + const paged = await watch([ + snapshotAnswer(), + changesAnswer( + changed('p1', ['m1'], true), + [delivered('m1')], + 'sb3', + 'p9', + ), + changesAnswer(changed('p2', ['m2']), [delivered('m2')], 'sb3', 'p9'), + ]); + await signal(paged.streams[0]); + expect(argsOf(paged.recorded[2], 0)).toMatchObject({ sinceState: 'p1' }); + expect(paged.events[1]).toEqual([ + 'new-mail', + expect.objectContaining({ count: 2 }), + ]); + + const flooded = await watch([ + snapshotAnswer(), + ...['f1', 'f2', 'f3', 'f4'].map((state) => + changesAnswer( + changed(state, ['m1'], true), + [delivered('m1')], + 'sb3', + 'now', + ), + ), + changesAnswer(changed('later', []), [], 'sb3', 'later'), + ]); + await signal(flooded.streams[0]); + expect(flooded.recorded).toHaveLength(5); + expect(flooded.events.map(([type]) => type)).toEqual(['state']); + await signal(flooded.streams[0]); + expect(argsOf(flooded.recorded[5], 0)).toMatchObject({ + sinceState: 'now', + }); + }); + + it('받은편지함을 찾지 못하면(id 가 바뀜) 모든 메일함에서 다시 찾아 안 읽은 수와 새 메일을 그것으로 센다', async () => { + const { recorded, streams, events } = await watch([ + snapshotAnswer(), + changesAnswer( + changed('sak', ['m1']), + [delivered('m1', { mailboxIds: { z: true } })], + 'sb3', + 'sal', + ).map((invocation) => + invocation[2] === 'm' + ? ([ + 'Mailbox/get', + { state: 'sb3', list: [], notFound: ['a'] }, + 'm', + ] as JmapInvocation) + : invocation, + ), + [ + [ + 'Mailbox/get', + { + state: 'sb3', + list: [ + { id: 'z', name: 'Inbox', role: 'inbox', unreadEmails: 7 }, + ], + }, + 'm', + ], + ], + ]); + + await signal(streams[0]); + + expect(recorded[2].calls[0][0]).toBe('Mailbox/get'); + expect(argsOf(recorded[2])).toMatchObject({ ids: null }); + expect(events).toEqual([ + ['state', expect.objectContaining({ inboxUnread: 7 })], + ['new-mail', expect.objectContaining({ count: 1 })], + ]); + }); + }); + describe('여러 통 바꾸기 (updateMessages)', () => { let warnSpy: jest.SpyInstance; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index c077773..e580e21 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -23,9 +23,12 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailLiveState, MailState, MailUsage, MailVacation, + MailWatch, + MailWatchListener, MessageDeletionResult, MessageDetailSource, MessageListQuery, @@ -53,6 +56,12 @@ import { type JmapSession, } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { + JmapMailWatch, + type WatchPosition, + type WatchReading, +} from './jmap-mail-watch'; +import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push'; import { isMissingAttachmentBlob, jmapFailure, @@ -79,6 +88,7 @@ import { } from './jmap.mapper'; import type { JmapBlob, + JmapChangesResult, JmapEmail, JmapEmailFilter, JmapEmailFilterCondition, @@ -92,6 +102,7 @@ import type { JmapSetResult, JmapVacationResponse, } from './jmap.types'; +import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail'; import { searchTextConditions, withSearchConditions } from './search-terms'; const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota'; @@ -558,6 +569,90 @@ function combineMailStates(mailboxState: string, emailState: string): string { return `${encode(mailboxState)}.${encode(emailState)}`; } +/** 메일 상태를 읽은 것 — 클라이언트에 주는 값과, 실시간 알림이 다음에 물을 엔진의 메일 상태 · 함께 읽은 메일함 */ +interface MailStatesRead { + /** GET /api/mail-state · 실시간 알림의 state (combineMailStates) */ + readonly state: string; + readonly emailState: string; + readonly mailboxes: JmapMailbox[]; +} + +/** + * 같은 요청의 `Mailbox/get`('m') · `Email/get`('e') 응답에서 상태를 읽는다 — 새 메일 확인(getMailState)과 실시간 알림이 + * **같은 함수**로 만든다(값이 어긋나면 클라이언트가 쓸데없이 다시 맞춘다). 엔진이 상태를 알려 주지 않으면 MAIL_001 + */ +function readMailStates(responses: JmapInvocation[]): MailStatesRead { + const mailboxes = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const emails = expectResult>( + responses, + 'e', + 'Email/get', + ); + if (!isState(mailboxes.state) || !isState(emails.state)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-state-missing' }, + }); + } + return { + state: combineMailStates(mailboxes.state, emails.state), + emailState: emails.state, + mailboxes: mailboxes.list ?? [], + }; +} + +/** 실시간 알림이 메일함에서 읽는 속성 — 받은편지함을 찾고 안 읽은 수를 센다 */ +const LIVE_MAILBOX_PROPERTIES = ['id', 'role', 'unreadEmails']; + +/** Email/changes 한 번에 받는 변화 수 — created 를 같은 요청의 Email/get 으로 읽으므로 엔진의 maxObjectsInGet(500) 이하 */ +const LIVE_MAX_CHANGES = 500; +/** 한 번 알릴 때 Email/changes 를 이어 묻는 횟수 — 넘게 바뀌었으면(대량 가져오기 등) 이번에는 새 메일을 알리지 않는다 */ +const LIVE_MAX_CHANGE_PAGES = 4; +/** Email/changes 의 메서드 오류 — 그 상태부터는 셀 수 없다 (너무 오래됨 등, RFC 8620 §5.2) */ +const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges'; + +/** 엔진이 요청 전체를 거부했다(HTTP 400 notRequest) — jmap.client.ts 의 call 이 invalidRequestCode 로 바꿔 던진 것 */ +function isRejectedRequest(error: unknown): boolean { + return ( + error instanceof BusinessException && + error.context?.reason === 'request-rejected' + ); +} + +function findInbox(mailboxes: readonly JmapMailbox[]): JmapMailbox | undefined { + return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === 'inbox'); +} + +/** 실시간 알림의 상태 — 받은편지함이 없으면 안 읽은 수 0 */ +function toLiveState( + states: MailStatesRead, + inbox: JmapMailbox | undefined, +): MailLiveState { + return { state: states.state, inboxUnread: inbox?.unreadEmails ?? 0 }; +} + +/** 메일함 · 메일 상태를 읽는 호출 ('m' · 'e' — readMailStates 가 읽는다). 받은편지함을 알면 그것만 읽는다 */ +function liveStateCalls( + accountId: string, + inboxId: string | null, +): JmapInvocation[] { + return [ + [ + 'Mailbox/get', + { + accountId, + ids: inboxId === null ? null : [inboxId], + properties: LIVE_MAILBOX_PROPERTIES, + }, + 'm', + ], + ['Email/get', { accountId, ids: [] }, 'e'], + ]; +} + /** 메일에 함께 싣는 그림 — 엔진에 올린 blob */ interface UploadedInlineImage { readonly blobId: string; @@ -1188,22 +1283,29 @@ export class JmapMailProvider implements MailProvider { ['Mailbox/get', { accountId, ids: [] }, 'm'], ['Email/get', { accountId, ids: [] }, 'e'], ]); - const mailboxes = expectResult>( - responses, - 'm', - 'Mailbox/get', + // 실시간 알림의 state 도 이 함수로 만든다 + return { state: readMailStates(responses).state }; + } + + /** + * 실시간 알림 (계약 2절 "실시간 알림") — 엔진의 EventSource(대리 접근 · types=Mailbox,Email · ping=30 · closeafter=no)를 하나 연다. + * 연결 · 다시 잇기 · 무소식 감시 · 신호 모으기는 JmapMailWatch, 엔진에 묻는 모양은 readLiveSnapshot · readLiveChanges 다. + */ + watchMail(user: string, listener: MailWatchListener): Promise { + return JmapMailWatch.start( + { + open: (signal) => + this.jmap.openEventSource( + user, + { types: PUSH_TYPES, pingSeconds: PUSH_PING_SECONDS }, + signal, + ), + snapshot: (timeoutMs) => this.readLiveSnapshot(user, timeoutMs), + changes: (position) => this.readLiveChanges(user, position), + }, + listener, + maskEmail(user), ); - const emails = expectResult>( - responses, - 'e', - 'Email/get', - ); - if (!isState(mailboxes.state) || !isState(emails.state)) { - throw new BusinessException('MAIL_001', { - context: { reason: 'mail-state-missing' }, - }); - } - return { state: combineMailStates(mailboxes.state, emails.state) }; } async listMessages( @@ -2392,6 +2494,164 @@ export class JmapMailProvider implements MailProvider { return list; } + /** + * 실시간 알림의 처음 상태 — 요청 한 번: 모든 메일함(받은편지함을 찾는다) · 메일 상태 · 발신 ID(본인 주소). + * 상태는 getMailState 와 같은 함수(readMailStates)로 만든다. 발신 ID 를 읽지 못하면 직원 주소만 본인으로 본다. + * `timeoutMs` 는 처음 연결의 남은 시간 — 세션 조회까지 그 안에서 한다 (클라이언트가 응답 머리를 기다린다). 없으면 기본 제한 시간 + */ + private async readLiveSnapshot( + user: string, + timeoutMs?: number, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + ...liveStateCalls(accountId, null), + ['Identity/get', { accountId, ids: null, properties: ['email'] }, 'i'], + ], + timeoutMs === undefined ? {} : { timeoutMs }, + ); + const states = readMailStates(responses); + const inbox = findInbox(states.mailboxes); + + const identities = findResult(responses, 'i', 'Identity/get'); + const identityEmails = identities.ok + ? ((identities.args as JmapGetResult).list ?? []).map( + (identity) => identity.email, + ) + : []; + const ownAddresses = new Set( + [user, ...identityEmails].flatMap((email) => + typeof email === 'string' && email.trim() !== '' + ? [email.trim().toLowerCase()] + : [], + ), + ); + + return { + live: toLiveState(states, inbox), + position: { + emailState: states.emailState, + inboxId: inbox?.id ?? null, + ownAddresses, + }, + newMail: null, + }; + } + + /** + * 실시간 알림의 변화 — 기준(지난 메일 상태)부터 생긴 메일을 엔진이 알려 준 대로(Email/changes 의 created) 읽어 새 메일을 고르고, + * 같은 요청에서 지금 상태를 읽는다 (요청 한 번에 Email/changes · Email/get(#created) · 받은편지함 · 메일 상태). + * - 생성은 배달이 아니다 — 변화를 본 시각보다 10분 넘게 앞서 받은 메일(IMAP 으로 올린 옛 메일 · 옮겨 넣은 메일)은 고르지 않는다 (new-mail.ts) + * - 변화가 한 번에 다 오지 않으면(hasMoreChanges) 이어 묻는다 — LIVE_MAX_CHANGE_PAGES 번을 넘으면 이번에는 새 메일 없이 + * - 셀 수 없으면(cannotCalculateChanges) 새 메일 없이 상태만 — 다음 기준은 지금 상태 + * - 다음 기준은 마지막 Email/changes 의 newState — 같은 요청에서 그 뒤에 생긴 메일은 다음에 센다(놓치지 않는다) + * - 받은편지함을 찾지 못하면(id 가 바뀜) 모든 메일함에서 다시 찾는다 + * - 엔진이 요청 전체를 거부하면(400 notRequest) 셀 수 없는 것과 같게 — 처음 상태를 다시 잡아 새 기준으로 (새 메일 없이). + * Stalwart 0.16.27 은 해석하지 못하는 sinceState(엔진 판 · 데이터가 바뀐 뒤의 옛 기준 등)를 cannotCalculateChanges 가 아니라 + * 이렇게 답한다 (2026-10-28 개발 엔진에서 확인). 그대로 던지면 다시 이을 때마다 같은 기준으로 거부되어 끝없이 다시 잇는다 + */ + private async readLiveChanges( + user: string, + position: WatchPosition, + ): Promise { + try { + return await this.countLiveChanges(user, position); + } catch (error) { + if (!isRejectedRequest(error)) throw error; + return this.readLiveSnapshot(user); + } + } + + /** readLiveChanges 의 본체 — 엔진이 요청을 거부하면 `request-rejected` 로 던진다 */ + private async countLiveChanges( + user: string, + position: WatchPosition, + ): Promise { + const created: JmapEmail[] = []; + let since = position.emailState; + let countable = true; + let responses: JmapInvocation[] = []; + + for (let page = 1; ; page += 1) { + const sinceState = since; + responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/changes', + { accountId, sinceState, maxChanges: LIVE_MAX_CHANGES }, + 'c', + ], + [ + 'Email/get', + { + accountId, + '#ids': { + resultOf: 'c', + name: 'Email/changes', + path: '/created', + }, + properties: NEW_MAIL_PROPERTIES, + }, + 'n', + ], + ...liveStateCalls(accountId, position.inboxId), + ], + // 요청 전체 거부를 엔진 장애와 가른다 (readLiveChanges 가 받는다) + { invalidRequestCode: 'MAIL_001' }, + ); + + const changes = findResult(responses, 'c', 'Email/changes'); + if (!changes.ok) { + if (changes.error.type !== CANNOT_CALCULATE_CHANGES) { + throw jmapFailure(changes.error, { method: 'Email/changes' }); + } + countable = false; + break; + } + const result = changes.args as JmapChangesResult; + if (!isState(result.newState)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'changes-state-missing' }, + }); + } + const { list = [] } = expectResult>( + responses, + 'n', + 'Email/get', + ); + created.push(...list); + since = result.newState; + + if (result.hasMoreChanges !== true) break; + if (page >= LIVE_MAX_CHANGE_PAGES) { + countable = false; + break; + } + } + + const states = readMailStates(responses); + let inbox = findInbox(states.mailboxes); + if (!inbox && position.inboxId !== null) { + inbox = findInbox(await this.loadMailboxes(user)); + } + + return { + live: toLiveState(states, inbox), + position: { + ...position, + emailState: countable ? since : states.emailState, + inboxId: inbox?.id ?? null, + }, + // 변화를 본 시각 — 그보다 오래전에 받은 메일(IMAP 으로 올린 옛 메일 등)은 알리지 않는다 (new-mail.ts) + newMail: + countable && inbox + ? selectNewMail(created, inbox.id, position.ownAddresses, Date.now()) + : null, + }; + } + /** * 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다), * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다 diff --git a/backend/src/modules/mail/jmap/jmap-push.spec.ts b/backend/src/modules/mail/jmap/jmap-push.spec.ts new file mode 100644 index 0000000..f962280 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-push.spec.ts @@ -0,0 +1,51 @@ +import { isMailStateChange } from './jmap-push'; + +/** Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 개발 엔진에서 받은 것) */ +const stateChange = (changed: Record>) => + JSON.stringify({ '@type': 'StateChange', changed }); + +describe('isMailStateChange', () => { + it('이 계정의 메일 · 메일함 변화면 true — 배달처럼 여러 종류가 함께 와도', () => { + expect( + isMailStateChange( + stateChange({ + d: { + Thread: 's1', + Mailbox: 's2', + EmailDelivery: 's3', + Email: 's4', + }, + }), + 'd', + ), + ).toBe(true); + expect(isMailStateChange(stateChange({ d: { Mailbox: 's2' } }), 'd')).toBe( + true, + ); + }); + + it('다른 계정(공유 메일함)만의 변화 · 메일 · 메일함이 아닌 종류만의 변화는 false', () => { + expect(isMailStateChange(stateChange({ x: { Email: 's1' } }), 'd')).toBe( + false, + ); + expect( + isMailStateChange( + stateChange({ d: { Thread: 's1', EmailDelivery: 's2' } }), + 'd', + ), + ).toBe(false); + }); + + it('형식이 틀린 data 는 false — JSON 아님 · @type 다름 · changed 없음', () => { + for (const data of [ + 'not json', + '{"interval": 30}', + JSON.stringify({ '@type': 'Other', changed: { d: { Email: 's1' } } }), + JSON.stringify({ '@type': 'StateChange' }), + JSON.stringify({ '@type': 'StateChange', changed: { d: 'Email' } }), + 'null', + ]) { + expect(isMailStateChange(data, 'd')).toBe(false); + } + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-push.ts b/backend/src/modules/mail/jmap/jmap-push.ts new file mode 100644 index 0000000..cbdeb13 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-push.ts @@ -0,0 +1,47 @@ +/** + * 메일 엔진의 변화 소식(JMAP push — RFC 8620 7) 해석 (순수 함수). + * + * Stalwart 0.16.27 의 EventSource (2026-10-28 개발 엔진에서 확인): + * - 변화: `event: state` / `data: {"@type":"StateChange","changed":{"":{"Email":"…","Mailbox":"…", …}}}` + * - 연결 유지: `event: ping` / `data: {"interval": 30}` — 30초보다 짧게 오지 않는다(10을 달라고 해도 30초, 0 이면 오지 않는다) + * - 연결 직후에는 아무것도 오지 않는다 (첫 변화나 첫 ping 까지 조용하다) + * - 초안을 만들어도 `EmailDelivery` 가 바뀐다 — 새 메일 판단에 쓰지 않는다 (new-mail.ts 는 Email/changes 로 한다) + */ + +/** 엔진에 달라는 종류 — 메일함 · 메일 (계약 2절 "실시간 알림") */ +export const PUSH_TYPES = ['Mailbox', 'Email'] as const; + +/** 엔진의 연결 유지 간격(초) — 엔진이 받아 주는 가장 짧은 값 */ +export const PUSH_PING_SECONDS = 30; + +/** 변화 소식의 SSE 이벤트 이름 (ping 은 연결 유지) */ +export const PUSH_STATE_EVENT = 'state'; + +const STATE_CHANGE_TYPE = 'StateChange'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * `state` 이벤트의 data 가 이 계정의 메일함 · 메일 변화인가. + * 다른 계정(공유 메일함)만의 변화 · 다른 종류만의 변화(Thread 등) · 형식이 틀린 data 는 false — + * 형식이 틀려도 연결은 살아 있는 것으로 본다(끊김은 받은 바이트로 잰다). + */ +export function isMailStateChange(data: string, accountId: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return false; + } + if (!isRecord(parsed) || parsed['@type'] !== STATE_CHANGE_TYPE) return false; + + const { changed } = parsed; + if (!isRecord(changed)) return false; + const account = changed[accountId]; + if (!isRecord(account)) return false; + return PUSH_TYPES.some((type) => + Object.prototype.hasOwnProperty.call(account, type), + ); +} diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index a0996dc..a0037fb 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -32,6 +32,8 @@ const sessionBody = { downloadUrl: 'http://localhost:8180/jmap/download/{accountId}/{blobId}/{name}?accept={type}', uploadUrl: 'http://localhost:8180/jmap/upload/{accountId}/', + eventSourceUrl: + 'http://localhost:8180/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'd' }, }; @@ -448,6 +450,8 @@ describe('JmapClient', () => { uploadPath: '/jmap/upload/{accountId}/', // 계정 기능을 알려 주지 않는 응답 — 엔진 한도를 모르는 것으로 둔다 maxAttachmentsBytes: null, + eventSourcePath: + '/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}', }); }); @@ -875,6 +879,197 @@ describe('JmapClient', () => { }); }); + describe('변화 소식 스트림 (openEventSource)', () => { + const OPTIONS = { types: ['Mailbox', 'Email'], pingSeconds: 30 } as const; + + /** Stalwart 의 EventSource 응답 — text/event-stream, 본문은 닫을 때까지 이어진다 */ + const eventStream = (text = '') => + new Response(text, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + + const errorLogOf = () => + jest + .spyOn( + Reflect.get(client, 'logger') as { error: (message: string) => void }, + 'error', + ) + .mockImplementation(() => undefined); + + it('세션의 eventSourceUrl 경로를 내부 주소에 붙여 대리 접근 자격으로 연다 — types 는 쉼표로 잇고 closeafter=no · ping', async () => { + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(eventStream('event: ping\ndata: {}\n\n')); + + const stream = await client.openEventSource( + ARA, + OPTIONS, + new AbortController().signal, + ); + + const { url, init, authorization } = requestAt(1); + expect(url).toBe( + 'http://mail:8080/jmap/eventsource/?types=Mailbox,Email&closeafter=no&ping=30', + ); + expect(decodeBasic(authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect((init.headers as Record).accept).toBe( + 'text/event-stream', + ); + expect(init.redirect).toBe('manual'); + expect(stream.accountId).toBe('d'); + + const decoder = new TextDecoder(); + let text = ''; + for await (const chunk of stream.body) text += decoder.decode(chunk); + expect(text).toBe('event: ping\ndata: {}\n\n'); + }); + + it('응답 머리까지만 제한 시간(10초)을 둔다 — 그 뒤 본문은 넘긴 signal 로 닫을 때까지 이어진다', async () => { + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(eventStream()); + const reader = new AbortController(); + + await client.openEventSource(ARA, OPTIONS, reader.signal); + const { signal } = requestAt(1).init; + await jest.advanceTimersByTimeAsync(120_000); + expect(signal?.aborted).toBe(false); + + reader.abort(); + expect(signal?.aborted).toBe(true); + }); + + it('응답 머리가 10초 안에 오지 않으면 MAIL_001 (timeout) · 읽는 쪽이 먼저 닫으면 closed', async () => { + jest.useFakeTimers(); + const hang = (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockImplementationOnce(hang); + + const pending = client + .openEventSource(ARA, OPTIONS, new AbortController().signal) + .catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(10_000); + expect(((await pending) as BusinessException).context).toMatchObject({ + reason: 'timeout', + call: 'event-source', + }); + + // 세션은 캐시에 있다 — 이번에는 스트림 요청만 + fetchMock.mockImplementationOnce(hang); + const reader = new AbortController(); + const closed = client + .openEventSource(ARA, OPTIONS, reader.signal) + .catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(1_000); + reader.abort(); + const error = (await closed) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ reason: 'closed' }); + }); + + it('이 경로를 알기 전에 캐시한 세션이면 세션을 다시 받는다 — 엔진이 알려 주지 않으면 스트림을 요청하지 않고 MAIL_001', async () => { + const { eventSourceUrl: _omitted, ...withoutEventSource } = sessionBody; + void _omitted; + await cache.set(`mail:jmap-session:${ARA}`, { + username: ARA, + accountId: 'd', + apiPath: '/jmap/', + downloadPath: + '/jmap/download/{accountId}/{blobId}/{name}?accept={type}', + uploadPath: '/jmap/upload/{accountId}/', + }); + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(eventStream()); + + await client.openEventSource(ARA, OPTIONS, new AbortController().signal); + expect(requestAt(0).url).toBe('http://mail:8080/jmap/session'); + expect(requestAt(1).url).toContain('/jmap/eventsource/'); + + fetchMock.mockReset(); + // 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다 + fetchMock.mockImplementation(() => + Promise.resolve(json(withoutEventSource)), + ); + const other = 'doyun.lee@arasystem.test'; + const error = (await client + .openEventSource(other, OPTIONS, new AbortController().signal) + .catch((caught: unknown) => caught)) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ reason: 'event-source-missing' }); + expect( + (fetchMock.mock.calls as [string][]).every(([url]) => + url.endsWith('/jmap/session'), + ), + ).toBe(true); + }); + + it('대리 접근 401 · 403 은 다른 호출과 같은 분류 · 로그 — 계정이 사라짐 AUTH_001, 대리 권한 없음 MAIL_001 + error 로그', async () => { + const errorLog = errorLogOf(); + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(json({}, 401)) + // 게이트웨이 자신은 들어간다 — 계정이 사라졌다 + .mockResolvedValueOnce(json({}, 200)); + expect( + await codeOf( + client.openEventSource(ARA, OPTIONS, new AbortController().signal), + ), + ).toBe('AUTH_001'); + + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(json({}, 403)); + const code = await runWithRequestContext({ requestId: 'trace-es' }, () => + codeOf( + client.openEventSource(ARA, OPTIONS, new AbortController().signal), + ), + ); + expect(code).toBe('MAIL_001'); + expect(errorLog).toHaveBeenCalledWith( + expect.stringMatching( + /^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/, + ), + ); + }); + + it('엔진 한도 429 는 REQ_001 · 5xx · text/event-stream 이 아닌 답은 MAIL_001', async () => { + fetchMock.mockResolvedValueOnce(json(sessionBody)); + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect( + await codeOf( + client.openEventSource(ARA, OPTIONS, new AbortController().signal), + ), + ).toBe('REQ_001'); + + fetchMock.mockResolvedValueOnce(json({}, 502)); + expect( + await codeOf( + client.openEventSource(ARA, OPTIONS, new AbortController().signal), + ), + ).toBe('MAIL_001'); + + fetchMock.mockResolvedValueOnce(json({ ok: true })); + const error = (await client + .openEventSource(ARA, OPTIONS, new AbortController().signal) + .catch((caught: unknown) => caught)) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ reason: 'not-event-stream' }); + }); + }); + describe('첨부 업로드·다운로드', () => { beforeEach(() => { fetchMock.mockResolvedValueOnce(json(sessionBody)); diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 115ede8..a87965a 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -152,6 +152,13 @@ export const JMAP_INLINE_UPLOAD_TIMEOUT_MS = 15_000; const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000; /** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */ const SMALL_DOWNLOAD_TIMEOUT_MS = 10_000; +/** + * 변화 소식 스트림은 응답 머리까지만 제한한다 — 엔진은 연결 직후 아무것도 보내지 않고(첫 변화나 첫 ping 까지 조용하다, + * 2026-10-28 확인) 본문은 닫을 때까지 이어진다. 끊김은 읽는 쪽이 무소식 시간으로 안다 (jmap-mail-watch.ts) + */ +const EVENT_SOURCE_HEADERS_TIMEOUT_MS = 10_000; +/** 변화 소식 스트림의 형식 — 다른 형식으로 답하면(엔진 설정 · 앞단 문제) 엔진 장애로 본다 */ +const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i; /** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */ const SESSION_CACHE_TTL_MS = 5 * 60_000; @@ -175,6 +182,27 @@ export interface JmapSession { readonly uploadPath: string; /** 엔진의 메일 한 통 첨부 합계 한도(바이트) — 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다) */ readonly maxAttachmentsBytes?: number | null; + /** + * 변화 소식 스트림(EventSource) 경로 템플릿 — `{types}` · `{closeafter}` · `{ping}` 이 남아 있다. + * 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다 — openEventSource 가 세션을 다시 받는다) + */ + readonly eventSourcePath?: string | null; +} + +/** 엔진의 변화 소식 스트림(JMAP EventSource)을 열 때 정하는 것 */ +export interface JmapEventSourceOptions { + /** 받을 종류 — Mailbox · Email 등 (RFC 8620 7.3 의 types) */ + readonly types: readonly string[]; + /** 엔진이 연결 유지(ping)를 보내는 간격(초) — 0 이면 보내지 않는다 */ + readonly pingSeconds: number; +} + +/** 열린 변화 소식 스트림 — 본문은 openEventSource 에 넘긴 signal 로 닫을 때까지 이어진다 */ +export interface JmapEventStream { + /** 이 세션의 메일 계정 id — StateChange 의 `changed` 에서 이 계정을 찾는다 */ + readonly accountId: string; + /** text/event-stream 본문 (받은 바이트 그대로) */ + readonly body: AsyncIterable; } export interface JmapDownload { @@ -791,6 +819,89 @@ export class JmapClient { }; } + /** + * 그 직원의 변화 소식 스트림(JMAP EventSource, RFC 8620 7.3)을 연다 — 대리 접근 자격, 실시간 알림이 쓴다 (계약 2절 "실시간 알림"). + * - 주소는 세션의 `eventSourceUrl` 경로를 MAIL_JMAP_URL 에 붙인다(다른 세션 주소와 같다). `closeafter=no` — 닫을 때까지 이어진다 + * - `types` 는 이름마다 인코딩하고 쉼표로 잇는다 (쉼표는 쿼리에 그대로 둘 수 있는 구분자다) + * - 응답 머리까지만 제한 시간(EVENT_SOURCE_HEADERS_TIMEOUT_MS)을 두고, 본문은 `signal` 로 닫을 때까지 읽는다 — + * 일반 호출의 10초 제한을 쓰지 않는다 + * - 401 · 403 은 다른 호출과 같은 분류 · 로그(assertImpersonationAccepted), 429 는 REQ_001, + * 그 밖의 실패 · text/event-stream 이 아닌 답은 MAIL_001 + */ + async openEventSource( + user: string, + options: JmapEventSourceOptions, + signal: AbortSignal, + ): Promise { + let session = await this.getSession(user); + // 이 경로를 알기 전에 캐시한 세션이면 다시 받는다 + if (!session.eventSourcePath) { + session = await this.getSession(user, { fresh: true }); + } + if (!session.eventSourcePath) { + throw engineFailure({ + reason: 'event-source-missing', + call: 'event-source', + }); + } + const path = expandTemplate(session.eventSourcePath, { + closeafter: 'no', + ping: String(options.pingSeconds), + }).replace('{types}', options.types.map(encodeURIComponent).join(',')); + + const { jmapBaseUrl } = this.requireConfigured(); + const authorization = this.impersonation(user); + // 머리 제한 시간과 읽는 쪽의 닫기를 한 신호로 — 본문도 이 신호로 닫힌다 + const controller = new AbortController(); + const close = () => controller.abort(); + if (signal.aborted) close(); + else signal.addEventListener('abort', close, { once: true }); + const timer = setTimeout(close, EVENT_SOURCE_HEADERS_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(`${jmapBaseUrl}${path}`, { + headers: { authorization, accept: 'text/event-stream' }, + // 엔진이 다른 곳으로 보내면 따라가지 않는다 (인증 헤더가 다른 호스트로 가지 않게) + redirect: 'manual', + signal: controller.signal, + }); + } catch (error) { + signal.removeEventListener('abort', close); + // 읽는 쪽이 닫았으면 closed, 머리를 기다리다 끊었으면 timeout + const reason = signal.aborted + ? 'closed' + : controller.signal.aborted + ? 'timeout' + : 'network'; + throw engineFailure({ + reason, + call: 'event-source', + cause: describeError(error), + }); + } finally { + clearTimeout(timer); + } + + try { + await this.assertImpersonationAccepted(response, user); + await this.assertOk(response, 'event-source'); + const type = response.headers.get('content-type') ?? ''; + if (!EVENT_STREAM_TYPE.test(type) || !response.body) { + await discard(response); + throw engineFailure({ + reason: 'not-event-stream', + call: 'event-source', + contentType: type, + }); + } + return { accountId: session.accountId, body: response.body }; + } catch (error) { + signal.removeEventListener('abort', close); + throw error; + } + } + /** * 작은 blob 을 메모리로 읽는다. `maxBytes` 를 넘으면 즉시 멈추고 null. * (본문 내장 이미지를 data: URI 로 넣을 때 쓴다) @@ -1001,7 +1112,7 @@ export class JmapClient { private toSession(resource: JmapSessionResource, user: string): JmapSession { const accountId = resource.primaryAccounts?.[MAIL_CAPABILITY]; - const { apiUrl, downloadUrl, uploadUrl } = resource; + const { apiUrl, downloadUrl, uploadUrl, eventSourceUrl } = resource; if (!accountId || !apiUrl || !downloadUrl || !uploadUrl) { throw engineFailure({ reason: 'session-incomplete', call: 'session' }); @@ -1013,6 +1124,10 @@ export class JmapClient { downloadPath: toRelativeReference(downloadUrl), uploadPath: toRelativeReference(uploadUrl), maxAttachmentsBytes: readAttachmentsLimit(resource, accountId), + // 실시간 알림만 쓴다 — 없다고 다른 기능까지 막지 않는다 (openEventSource 가 MAIL_001) + eventSourcePath: eventSourceUrl + ? toRelativeReference(eventSourceUrl) + : null, }; } } diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index bc41b54..941b400 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -24,6 +24,8 @@ export interface JmapSessionResource { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; + /** 변화 소식 스트림 주소 템플릿 (RFC 8620 7.3) — `{types}` · `{closeafter}` · `{ping}` */ + eventSourceUrl?: string; primaryAccounts?: Record; /** 계정별 기능·한도 — 메일 기능의 `maxSizeAttachmentsPerEmail` 를 읽는다 */ accounts?: Record< @@ -122,6 +124,17 @@ export interface JmapGetResult { notFound?: string[]; } +/** Foo/changes 결과 (RFC 8620 §5.2) — sinceState 부터 생기고 · 바뀌고 · 없어진 id */ +export interface JmapChangesResult { + oldState?: string; + newState?: string; + /** true 면 maxChanges 에 걸려 남은 변화가 있다 — newState 부터 다시 묻는다 */ + hasMoreChanges?: boolean; + created?: string[]; + updated?: string[]; + destroyed?: string[]; +} + export interface JmapSetResult { /** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */ oldState?: string | null; diff --git a/backend/src/modules/mail/jmap/new-mail.spec.ts b/backend/src/modules/mail/jmap/new-mail.spec.ts new file mode 100644 index 0000000..e2a8d2e --- /dev/null +++ b/backend/src/modules/mail/jmap/new-mail.spec.ts @@ -0,0 +1,174 @@ +import { + NEW_MAIL_MAX_AGE_MS, + NEW_MAIL_PREVIEW_LIMIT, + selectNewMail, +} from './new-mail'; +import type { JmapEmail } from './jmap.types'; + +const INBOX = 'a'; +const ME = 'ara.kim@arasystem.test'; +const OWN = new Set([ME, 'ara@alias.arasystem.test']); +/** 변화를 본 시각 — 아래 메일들은 그 몇 분 전에 받았다 */ +const NOW = Date.parse('2026-10-28T05:45:00Z'); + +/** 받은편지함에 막 배달된 안 읽은 메일 — 필드를 바꿔 가며 거르는지 본다 */ +const delivered = ( + id: string, + receivedAt: string, + extra: Partial = {}, +): JmapEmail => ({ + id, + mailboxIds: { [INBOX]: true }, + keywords: {}, + from: [{ name: '박서준', email: 'seojun.park@arasystem.test' }], + subject: `제목 ${id}`, + receivedAt, + ...extra, +}); + +describe('selectNewMail', () => { + it('받은편지함에 있고 · 초안이 아니고 · 읽지 않았고 · 내가 보내지 않은 메일만 — 다른 메일함 · 초안 · 읽음 · 본인(별칭 · 대소문자 포함)은 뺀다', () => { + const notice = selectNewMail( + [ + delivered('m1', '2026-10-28T05:40:00Z'), + delivered('draft', '2026-10-28T05:41:00Z', { + keywords: { $draft: true }, + }), + delivered('seen', '2026-10-28T05:41:00Z', { + keywords: { $seen: true }, + }), + delivered('mine', '2026-10-28T05:41:00Z', { + from: [{ name: '김아라', email: 'ARA.KIM@arasystem.test' }], + }), + delivered('alias', '2026-10-28T05:41:00Z', { + from: [{ name: null, email: 'ara@alias.arasystem.test' }], + }), + delivered('spam', '2026-10-28T05:41:00Z', { + mailboxIds: { c: true }, + }), + delivered('removed', '2026-10-28T05:41:00Z', { + mailboxIds: { [INBOX]: false }, + }), + ], + INBOX, + OWN, + NOW, + ); + + expect(notice).toEqual({ + count: 1, + messages: [ + { + id: 'm1', + from: { name: '박서준', email: 'seojun.park@arasystem.test' }, + subject: '제목 m1', + receivedAt: '2026-10-28T05:40:00.000Z', + }, + ], + }); + }); + + it('받은 시각 새것부터 최대 5통 · count 는 거른 전체 수 · 받은편지함과 다른 메일함에 함께 있어도 알린다', () => { + const emails = Array.from({ length: 7 }, (_, index) => + delivered(`m${index}`, `2026-10-28T05:4${index}:00Z`, { + mailboxIds: { [INBOX]: true, archive: true }, + }), + ); + + const notice = selectNewMail(emails, INBOX, OWN, NOW); + + expect(notice?.count).toBe(7); + expect(notice?.messages.map((message) => message.id)).toEqual([ + 'm6', + 'm5', + 'm4', + 'm3', + 'm2', + ]); + expect(notice?.messages).toHaveLength(NEW_MAIL_PREVIEW_LIMIT); + }); + + it('주소 · 제목은 메일 목록과 같은 규칙 — 이름이 없으면 name null, 보낸 사람이 없으면 from null, 제목이 없으면 빈 문자열', () => { + const notice = selectNewMail( + [ + delivered('noname', '2026-10-28T05:42:00Z', { + from: [{ name: ' ', email: 'client@partner.test' }], + subject: null, + }), + delivered('nofrom', '2026-10-28T05:41:00Z', { from: null }), + ], + INBOX, + OWN, + NOW, + ); + + expect(notice?.messages).toEqual([ + { + id: 'noname', + from: { name: null, email: 'client@partner.test' }, + subject: '', + receivedAt: '2026-10-28T05:42:00.000Z', + }, + { + id: 'nofrom', + from: null, + subject: '제목 nofrom', + receivedAt: '2026-10-28T05:41:00.000Z', + }, + ]); + }); + + it('알릴 것이 없으면 null', () => { + expect(selectNewMail([], INBOX, OWN, NOW)).toBeNull(); + expect( + selectNewMail( + [ + delivered('seen', '2026-10-28T05:00:00Z', { + keywords: { $seen: true }, + }), + ], + INBOX, + OWN, + NOW, + ), + ).toBeNull(); + }); + + it(`변화를 본 시각보다 ${NEW_MAIL_MAX_AGE_MS / 60_000}분 넘게 앞서 받은 메일은 뺀다 — IMAP 으로 올린 옛 메일 · 메일 서버를 옮길 때(imapsync) 넣는 메일이 알림으로 쏟아지지 않게`, () => { + const notice = selectNewMail( + [ + delivered('fresh', '2026-10-28T05:44:00Z'), + // 딱 10분 전은 넣는다 + delivered('edge', new Date(NOW - NEW_MAIL_MAX_AGE_MS).toISOString()), + delivered( + 'late', + new Date(NOW - NEW_MAIL_MAX_AGE_MS - 1).toISOString(), + ), + // 옮겨 온 옛 메일 (받은 시각이 원래 값으로 남는다) + delivered('migrated', '2025-03-02T01:00:00Z'), + // 받은 시각을 읽지 못한 메일 + delivered('unknown', 'not a date'), + // 엔진 시계가 조금 앞서도 새 메일이다 + delivered('ahead', '2026-10-28T05:46:00Z'), + ], + INBOX, + OWN, + NOW, + ); + + expect(notice?.count).toBe(3); + expect(notice?.messages.map(({ id }) => id)).toEqual([ + 'ahead', + 'fresh', + 'edge', + ]); + expect( + selectNewMail( + [delivered('migrated', '2025-03-02T01:00:00Z')], + INBOX, + OWN, + NOW, + ), + ).toBeNull(); + }); +}); diff --git a/backend/src/modules/mail/jmap/new-mail.ts b/backend/src/modules/mail/jmap/new-mail.ts new file mode 100644 index 0000000..6c89da7 --- /dev/null +++ b/backend/src/modules/mail/jmap/new-mail.ts @@ -0,0 +1,80 @@ +import type { NewMailMessage, NewMailNotice } from '@/modules/mail/mail.types'; +import { toSummary } from './jmap.mapper'; +import type { JmapEmail } from './jmap.types'; + +/** + * 새 메일 판단 (계약 2절 "실시간 알림"의 `new-mail`) — 순수 함수. + * 엔진의 Email/changes 가 알려 준 "지난 상태부터 생긴 메일"(created)을 받아 알릴 것만 고른다. + * 엔진의 EmailDelivery 신호는 초안을 만들어도 바뀌므로 쓰지 않는다 (2026-10-28 확인). + */ + +/** 새로 생긴 메일에서 읽는 속성 — 고르는 데(메일함 · 키워드 · 보낸 사람)와 알림에 싣는 것(제목 · 받은 시각)만 */ +export const NEW_MAIL_PROPERTIES = [ + 'id', + 'mailboxIds', + 'keywords', + 'from', + 'subject', + 'receivedAt', +]; + +/** `new-mail` 에 싣는 최대 통수 — count 는 거른 전체 수다 */ +export const NEW_MAIL_PREVIEW_LIMIT = 5; + +/** + * 변화를 본 시각보다 이만큼 넘게 앞서 받은 메일은 알리지 않는다 (state 는 그대로 보낸다). + * Email/changes 의 created 는 "배달"이 아니라 "생성"이다 — 메일 프로그램이 IMAP 으로 올린(APPEND) 옛 메일, + * 메일 서버를 옮길 때(mailcow → 이 엔진, imapsync) 수천 통을 넣는 것도 생성이라 그대로 두면 알림이 쏟아진다. + * 그런 메일은 받은 시각(receivedAt — 옮겨도 원래 값이 남는다)이 오래됐다. + * 10분은 늦게 배달된 메일 · 끊겼다 다시 이은 동안 온 메일 · 5분 안전판(jmap-mail-watch.ts)으로 찾은 메일을 넉넉히 넣는다 + */ +export const NEW_MAIL_MAX_AGE_MS = 10 * 60_000; + +/** 알릴 메일인가 — 받은편지함에 있고 · `$draft` 아님 · `$seen` 아님 · 보낸 사람이 본인이 아님 */ +function isNotifiable( + email: JmapEmail, + inboxId: string, + ownAddresses: ReadonlySet, +): boolean { + if (email.mailboxIds?.[inboxId] !== true) return false; + if (email.keywords?.$draft === true || email.keywords?.$seen === true) { + return false; + } + const sender = email.from?.[0]?.email?.trim().toLowerCase(); + return !(sender && ownAddresses.has(sender)); +} + +/** 받은 시각(ms) — 읽지 못하면 가장 오래된 것으로 */ +function receivedTime(message: NewMailMessage): number { + const time = Date.parse(message.receivedAt); + return Number.isNaN(time) ? 0 : time; +} + +/** + * 새로 생긴 메일 가운데 알릴 것 — 받은 시각 새것부터 NEW_MAIL_PREVIEW_LIMIT 통, count 는 거른 전체 수. 없으면 null. + * 주소 · 제목은 메일 목록과 같은 규칙으로 만든다(toSummary) — 이름이 없으면 name 은 null, 제목이 없으면 빈 문자열. + * @param ownAddresses 본인 주소(소문자) — 나에게 보낸 메일 · 내 다른 주소(발신 ID)로 보낸 메일은 알리지 않는다 + * @param observedAt 변화를 본 시각(epoch ms) — 그보다 NEW_MAIL_MAX_AGE_MS 넘게 앞서 받은 메일은 뺀다 (받은 시각을 읽지 못한 메일도) + */ +export function selectNewMail( + emails: readonly JmapEmail[], + inboxId: string, + ownAddresses: ReadonlySet, + observedAt: number, +): NewMailNotice | null { + const oldest = observedAt - NEW_MAIL_MAX_AGE_MS; + const messages = emails + .filter((email) => isNotifiable(email, inboxId, ownAddresses)) + .map((email): NewMailMessage => { + const { id, from, subject, receivedAt } = toSummary(email); + return { id, from, subject, receivedAt }; + }) + .filter((message) => receivedTime(message) >= oldest) + .sort((a, b) => receivedTime(b) - receivedTime(a)); + + if (messages.length === 0) return null; + return { + count: messages.length, + messages: messages.slice(0, NEW_MAIL_PREVIEW_LIMIT), + }; +} diff --git a/backend/src/modules/mail/mail-events.controller.spec.ts b/backend/src/modules/mail/mail-events.controller.spec.ts new file mode 100644 index 0000000..f4f0e72 --- /dev/null +++ b/backend/src/modules/mail/mail-events.controller.spec.ts @@ -0,0 +1,155 @@ +import { EventEmitter } from 'events'; +import type { Response } from 'express'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { + MailEventsController, + eventStreamSink, +} from './mail-events.controller'; +import type { + MailEventSink, + MailEventsOpenOptions, + MailEventsService, +} from './mail-events.service'; + +const USER: AuthenticatedUser = { + email: 'ara.kim@arasystem.test', + name: '김아라', + accessTokenExpiresAt: 1_793_000_000_000, +}; + +/** express 응답 대역 — 컨트롤러 · 연결이 쓰는 것만 */ +class FakeResponse extends EventEmitter { + destroyed = false; + writableEnded = false; + headersSent = false; + statusCode = 0; + readonly headers = new Map(); + readonly written: string[] = []; + + status(code: number): this { + this.statusCode = code; + return this; + } + + setHeader(name: string, value: string): void { + this.headers.set(name.toLowerCase(), value); + } + + flushHeaders(): void { + this.headersSent = true; + } + + write(chunk: string): boolean { + this.written.push(chunk); + return true; + } + + end(): void { + this.writableEnded = true; + this.emit('close'); + } +} + +const asResponse = (res: FakeResponse) => res as unknown as Response; + +describe('MailEventsController', () => { + /** 서비스 대역 — 받은 연결 · 옵션을 모아 둔다 */ + function controllerWithFakeService() { + const opened: { sink: MailEventSink; options: MailEventsOpenOptions }[] = + []; + const open = jest.fn( + (_user: string, sink: MailEventSink, options: MailEventsOpenOptions) => { + opened.push({ sink, options }); + return Promise.resolve(); + }, + ); + const controller = new MailEventsController({ + open, + } as unknown as MailEventsService); + return { controller, open, opened }; + } + + it("처리기에 닿기 전에 끊긴 응답('close' 가 이미 지나감)은 열기 전에 끊긴 것으로 넘긴다", async () => { + const { controller, opened } = controllerWithFakeService(); + const res = new FakeResponse(); + res.destroyed = true; + + await controller.stream(USER, asResponse(res)); + + expect(opened[0].options.signal.aborted).toBe(true); + // 두 번째 그물 — 그런 응답에는 쓰지 않고 false + expect( + opened[0].sink.send({ type: 'ping', data: { upstream: true } }), + ).toBe(false); + expect(res.written).toEqual([]); + expect(res.headersSent).toBe(false); + }); + + it('연결한 뒤 클라이언트가 끊으면(res close) signal 을 끊는다 — 토큰 만료 시각을 넘긴다', async () => { + const { controller, open, opened } = controllerWithFakeService(); + const res = new FakeResponse(); + + await controller.stream(USER, asResponse(res)); + expect(open.mock.calls[0][0]).toBe(USER.email); + expect(opened[0].options.tokenExpiresAt).toBe(USER.accessTokenExpiresAt); + expect(opened[0].options.signal.aborted).toBe(false); + + res.emit('close'); + expect(opened[0].options.signal.aborted).toBe(true); + }); + + it('만료를 모르는 토큰이면 null 을 넘긴다 (서비스가 가장 긴 수명으로 닫는다)', async () => { + const { controller, opened } = controllerWithFakeService(); + + await controller.stream( + { email: USER.email, name: USER.name }, + asResponse(new FakeResponse()), + ); + + expect(opened[0].options.tokenExpiresAt).toBeNull(); + }); +}); + +describe('eventStreamSink (HTTP 응답 하나의 실시간 알림 연결)', () => { + it('처음 보낼 때 200 · 머리글을 바로 보내고(flushHeaders) 이벤트를 쓴다 — 머리글은 한 번만', () => { + const res = new FakeResponse(); + const sink = eventStreamSink(asResponse(res)); + + expect( + sink.send({ type: 'state', data: { state: 's0', inboxUnread: 2 } }), + ).toBe(true); + expect(sink.send({ type: 'ping', data: { upstream: true } })).toBe(true); + + expect(res.statusCode).toBe(200); + expect(Object.fromEntries(res.headers)).toEqual({ + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + 'x-accel-buffering': 'no', + }); + expect(res.headersSent).toBe(true); + expect(res.written).toEqual([ + 'event: state\ndata: {"state":"s0","inboxUnread":2}\n\n', + 'event: ping\ndata: {"upstream":true}\n\n', + ]); + }); + + it('끝났거나 끊긴 응답에는 쓰지 않고 false — 닫기는 한 번만 끝낸다', () => { + const ended = new FakeResponse(); + const sink = eventStreamSink(asResponse(ended)); + sink.close(); + sink.close(); + expect(ended.writableEnded).toBe(true); + expect(sink.send({ type: 'ping', data: { upstream: true } })).toBe(false); + + const destroyed = new FakeResponse(); + destroyed.destroyed = true; + expect( + eventStreamSink(asResponse(destroyed)).send({ + type: 'end', + data: { reason: 'server-shutdown' }, + }), + ).toBe(false); + expect(destroyed.written).toEqual([]); + }); +}); diff --git a/backend/src/modules/mail/mail-events.controller.ts b/backend/src/modules/mail/mail-events.controller.ts new file mode 100644 index 0000000..a20daeb --- /dev/null +++ b/backend/src/modules/mail/mail-events.controller.ts @@ -0,0 +1,126 @@ +import { Controller, Get, HttpStatus, Res, UseGuards } from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { Response } from 'express'; + +import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { formatSseEvent } from '@/shared/utils/sse'; +import { + MAIL_EVENTS_LINGER_MS, + MAIL_EVENTS_PING_MS, + MAX_MAIL_EVENT_CONNECTIONS, + MailEventsService, + TOKEN_EXPIRY_LEAD_MS, + type MailEventSink, +} from './mail-events.service'; + +/** 실시간 알림의 응답 형식 (계약 2절 "실시간 알림") */ +const EVENT_STREAM_CONTENT_TYPE = 'text/event-stream; charset=utf-8'; +/** 앞단(nginx · 외부 프록시)이 모아 두거나 고치지 않게 — 이벤트가 바로 가야 한다 */ +const EVENT_STREAM_CACHE_CONTROL = 'no-cache, no-transform'; + +const SECONDS = 1000; + +/** + * HTTP 응답 하나를 실시간 알림 연결로 — 처음 보낼 때 머리를 쓰고(그 전까지는 실패를 표준 JSON 으로 답할 수 있다), + * 끊긴 응답에는 쓰지 않고 false 를 돌려준다(서비스가 그 연결을 뺀다). + */ +export function eventStreamSink(res: Response): MailEventSink { + return { + send(event) { + if (res.writableEnded || res.destroyed) return false; + if (!res.headersSent) { + res.status(HttpStatus.OK); + res.setHeader('Content-Type', EVENT_STREAM_CONTENT_TYPE); + res.setHeader('Cache-Control', EVENT_STREAM_CACHE_CONTROL); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + } + res.write(formatSseEvent(event.type, event.data)); + return true; + }, + close() { + if (!res.writableEnded) res.end(); + }, + }; +} + +// 실시간 알림 (계약 2절 "실시간 알림") — 판단은 모두 MailEventsService 가 한다. +// 요청 수 한도는 다른 API 와 같다(SkipThrottle 하지 않는다) — 다시 잇기는 가끔이다(토큰 갱신 · 서버 재시작 · 끊김). +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@Controller('mail-events') +export class MailEventsController { + constructor(private readonly events: MailEventsService) {} + + @Get() + @SkipTransform() + @ApiOperation({ + summary: '실시간 알림 — 새 메일 · 메일함 변화 (SSE, 표준 래퍼 없음)', + description: + 'text/event-stream 으로 이어서 보낸다 — 각 이벤트는 `event: <이름>` · `data: ` · 빈 줄. ' + + '이벤트: ' + + '`state` {state, inboxUnread} — 연결 직후 한 번, 그 뒤 메일함 · 메일이 바뀔 때마다(메일 엔진 신호를 0.3초 모아서 — 신호가 없어도 5분에 한 번 확인한다). ' + + 'state 는 GET /api/mail-state 의 state 와 같은 값, inboxUnread 는 받은편지함의 안 읽은 수다. ' + + '`new-mail` {count, messages: [{id, from: {name, email} | null, subject, receivedAt}]} — 받은편지함에 새로 배달된 메일' + + '($draft · $seen · 내가 보낸 메일 · 변화를 본 시각보다 10분 넘게 앞서 받은 메일(옮겨 넣은 옛 메일 등)은 빼고), ' + + '받은 시각 새것부터 최대 5통(count 는 전체 수), 같은 변화의 state 다음에 온다. ' + + 'from 의 name 은 없으면 null, subject 는 없으면 빈 문자열(메일 목록과 같다). ' + + `\`ping\` {upstream} — ${MAIL_EVENTS_PING_MS / SECONDS}초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다` + + '(그동안 클라이언트는 1분 확인으로) — 바뀐 때는 바로 한 번 더 온다. ' + + `\`end\` {reason: token-expiring | server-shutdown} — 서버가 곧 닫는다(액세스 토큰이 끝나기 ${TOKEN_EXPIRY_LEAD_MS / SECONDS}초 전 · 서버 종료 · 재시작). ` + + `머리글: Content-Type: ${EVENT_STREAM_CONTENT_TYPE} · Cache-Control: ${EVENT_STREAM_CACHE_CONTROL} · X-Accel-Buffering: no. ` + + `직원마다 메일 엔진 연결 하나를 그 직원의 모든 연결이 나눠 쓴다(마지막 연결이 닫히고 ${MAIL_EVENTS_LINGER_MS / SECONDS}초 뒤 닫는다). ` + + `한 직원의 연결은 서버 인스턴스마다 ${MAX_MAIL_EVENT_CONNECTIONS}개까지다. 처음 state 를 보내기 전의 실패만 표준 실패 형식(JSON)이다.`, + }) + @ApiProduces(EVENT_STREAM_CONTENT_TYPE) + @ApiResponse({ + status: HttpStatus.OK, + description: '이벤트 스트림 (닫을 때까지 이어진다)', + schema: { + type: 'string', + example: + 'event: state\ndata: {"state":"c2Iy.c2Fq","inboxUnread":3}\n\n' + + 'event: ping\ndata: {"upstream":true}\n\n', + }, + }) + @ApiErrorResponse( + 'AUTH_001', + '로그인 필요 (쿠키 없음 · 토큰 만료) — 클라이언트는 /auth/refresh 를 한 번 시도한 뒤 다시 잇는다', + ) + @ApiErrorResponse( + 'REQ_001', + `한 직원의 연결이 ${MAX_MAIL_EVENT_CONNECTIONS}개를 넘음 (브라우저마다 하나 — 기기 · 브라우저 ${MAX_MAIL_EVENT_CONNECTIONS}개) · 요청 수 한도 · 메일 엔진 한도`, + ) + @ApiErrorResponse( + 'MAIL_001', + '처음 연결 때 메일 엔진에 연결할 수 없음 · 서버 종료 중', + ) + async stream( + @CurrentUser() user: AuthenticatedUser, + @Res() res: Response, + ): Promise { + // 클라이언트가 끊으면(기다리는 중이든 연결한 뒤든) 바로 정리한다 + const disconnected = new AbortController(); + res.once('close', () => disconnected.abort()); + // 처리기에 닿기 전에(가드 · 미들웨어를 지나는 사이) 끊겼으면 'close' 는 이미 지나가 다시 오지 않는다 — 끊긴 것으로 다룬다 + if (res.destroyed) disconnected.abort(); + + await this.events.open(user.email, eventStreamSink(res), { + tokenExpiresAt: user.accessTokenExpiresAt ?? null, + signal: disconnected.signal, + }); + } +} diff --git a/backend/src/modules/mail/mail-events.service.spec.ts b/backend/src/modules/mail/mail-events.service.spec.ts new file mode 100644 index 0000000..1a93f80 --- /dev/null +++ b/backend/src/modules/mail/mail-events.service.spec.ts @@ -0,0 +1,566 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + currentRequestId, + runWithRequestContext, +} from '@/shared/logger/request-context'; +import { + MAIL_EVENTS_LINGER_MS, + MAIL_EVENTS_PING_MS, + MAX_MAIL_EVENT_CONNECTIONS, + MailEventsService, + TOKEN_EXPIRY_LEAD_MS, + type MailEvent, + type MailEventSink, +} from './mail-events.service'; +import type { MailProvider } from './mail.provider'; +import type { + MailLiveState, + MailWatch, + MailWatchListener, + NewMailNotice, +} from './mail.types'; + +const ARA = 'ara.kim@arasystem.test'; +const DOYUN = 'doyun.lee@arasystem.test'; +const NOW = Date.parse('2026-10-28T05:00:00Z'); +const INITIAL: MailLiveState = { state: 's0', inboxUnread: 2 }; +const NEW_MAIL: NewMailNotice = { + count: 1, + messages: [ + { + id: 'm1', + from: { name: '박서준', email: 'seojun.park@arasystem.test' }, + subject: '안녕하세요', + receivedAt: '2026-10-28T05:42:00.000Z', + }, + ], +}; + +/** 엔진 연결 대역 — 연 것마다 listener 와 close 를 모아 둔다. `hold` 면 테스트가 열기를 끝낸다 */ +function fakeProvider() { + const opened: { + user: string; + listener: MailWatchListener; + close: jest.Mock; + traceId: string | undefined; + }[] = []; + let held: { + resolve: () => void; + reject: (error: unknown) => void; + } | null = null; + let hold = false; + + const watchMail = jest.fn( + (user: string, listener: MailWatchListener): Promise => { + const close = jest.fn(); + opened.push({ user, listener, close, traceId: currentRequestId() }); + const watch = { initial: INITIAL, close }; + if (!hold) return Promise.resolve(watch); + return new Promise((resolve, reject) => { + held = { resolve: () => resolve(watch), reject }; + }); + }, + ); + + return { + provider: { watchMail } as unknown as MailProvider, + watchMail, + opened, + /** 다음 열기를 테스트가 끝낼 때까지 붙잡는다 */ + holdNext: () => { + hold = true; + }, + release: () => { + hold = false; + held?.resolve(); + held = null; + }, + fail: (error: unknown) => { + hold = false; + held?.reject(error); + held = null; + }, + }; +} + +/** 클라이언트 연결 대역 — 보낸 이벤트와 닫힘을 기록한다. `die` 면 응답이 끊긴 것처럼 보내기가 false 다 ('close' 는 오지 않는다) */ +function client() { + const events: MailEvent[] = []; + const state = { closed: false, dead: false }; + const disconnect = new AbortController(); + const sink: MailEventSink = { + send: (event) => { + if (state.dead) return false; + events.push(event); + return true; + }, + close: () => { + state.closed = true; + // 응답을 끝내면 연결도 닫힌다 (컨트롤러의 res 'close' → abort) + disconnect.abort(); + }, + }; + return { + sink, + events, + get closed() { + return state.closed; + }, + /** 클라이언트가 끊는다 */ + leave: () => disconnect.abort(), + /** 응답이 끊겼는데 'close' 가 오지 않았다 (처리기에 닿기 전에 끊긴 응답 등) */ + die: () => { + state.dead = true; + }, + signal: disconnect.signal, + }; +} + +type Client = ReturnType; + +async function flush(): Promise { + for (let round = 0; round < 5; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +describe('MailEventsService (실시간 알림의 연결 관리)', () => { + let fake: ReturnType; + let service: MailEventsService; + + const connect = ( + user: string, + tokenExpiresAt: number | null = NOW + 3_600_000, + ) => { + const connection = client(); + const opening = service.open(user, connection.sink, { + tokenExpiresAt, + signal: connection.signal, + }); + return { connection, opening }; + }; + + const connected = async ( + user: string, + tokenExpiresAt?: number | null, + ): Promise => { + const { connection, opening } = connect(user, tokenExpiresAt); + await opening; + return connection; + }; + + const advance = async (ms: number) => { + await jest.advanceTimersByTimeAsync(ms); + await flush(); + }; + + beforeEach(() => { + jest.useFakeTimers({ + now: NOW, + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + fake = fakeProvider(); + service = new MailEventsService(fake.provider); + }); + + afterEach(() => { + service.beforeApplicationShutdown(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('한 직원의 연결 여럿은 엔진 연결 하나를 나눠 쓴다 — 각자 처음 state 를 받고, 엔진 소식(계산은 한 번)은 모두에게', async () => { + const first = await connected(ARA); + const second = await connected(ARA); + const third = await connected(ARA); + const other = await connected(DOYUN); + + expect(fake.watchMail).toHaveBeenCalledTimes(2); + expect(fake.opened.map(({ user }) => user)).toEqual([ARA, DOYUN]); + for (const connection of [first, second, third, other]) { + expect(connection.events).toEqual([{ type: 'state', data: INITIAL }]); + } + + const [ara] = fake.opened; + ara.listener.state({ state: 's1', inboxUnread: 3 }); + ara.listener.newMail(NEW_MAIL); + + for (const connection of [first, second, third]) { + expect(connection.events.slice(1)).toEqual([ + { type: 'state', data: { state: 's1', inboxUnread: 3 } }, + { type: 'new-mail', data: NEW_MAIL }, + ]); + } + expect(other.events).toHaveLength(1); + + // 나중에 들어온 연결은 마지막으로 알게 된 상태로 시작한다 (엔진에 다시 묻지 않는다) + const late = await connected(ARA); + expect(late.events).toEqual([ + { type: 'state', data: { state: 's1', inboxUnread: 3 } }, + ]); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); + + it('엔진 연결을 여는 중에 온 연결은 함께 기다린다 — 여는 것은 한 번', async () => { + fake.holdNext(); + const a = connect(ARA); + const b = connect(ARA); + await flush(); + expect(fake.watchMail).toHaveBeenCalledTimes(1); + expect(a.connection.events).toEqual([]); + + fake.release(); + await Promise.all([a.opening, b.opening]); + expect(a.connection.events).toEqual([{ type: 'state', data: INITIAL }]); + expect(b.connection.events).toEqual([{ type: 'state', data: INITIAL }]); + }); + + it('엔진 연결은 요청 컨텍스트 밖에서 연다 — 여러 요청이 함께 쓰므로 처음 요청의 추적 ID 를 물려받지 않는다', async () => { + await runWithRequestContext({ requestId: 'trace-first' }, () => + connected(ARA), + ); + + expect(fake.opened[0].traceId).toBeUndefined(); + }); + + it(`한 직원 ${MAX_MAIL_EVENT_CONNECTIONS}개를 넘으면 REQ_001 — 엔진 연결을 기다리는 연결도 센다, 하나가 닫히면 다시 된다`, async () => { + fake.holdNext(); + const waiting = Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () => + connect(ARA), + ); + const sixth = connect(ARA); + await expect(sixth.opening).rejects.toMatchObject({ code: 'REQ_001' }); + expect(sixth.connection.events).toEqual([]); + + fake.release(); + await Promise.all(waiting.map(({ opening }) => opening)); + await expect(connect(ARA).opening).rejects.toMatchObject({ + code: 'REQ_001', + }); + // 다른 직원은 따로 센다 + await connected(DOYUN); + + waiting[0].connection.leave(); + const again = await connected(ARA); + expect(again.events).toEqual([{ type: 'state', data: INITIAL }]); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); + + it(`마지막 연결이 닫히고 ${MAIL_EVENTS_LINGER_MS / 1000}초 뒤 엔진 연결을 닫는다 — 그 안에 다시 오면(새로 고침) 그대로 쓴다`, async () => { + const first = await connected(ARA); + first.leave(); + await advance(MAIL_EVENTS_LINGER_MS - 1); + expect(fake.opened[0].close).not.toHaveBeenCalled(); + + const reloaded = await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(1); + expect(reloaded.events).toEqual([{ type: 'state', data: INITIAL }]); + await advance(MAIL_EVENTS_LINGER_MS); + expect(fake.opened[0].close).not.toHaveBeenCalled(); + + reloaded.leave(); + await advance(MAIL_EVENTS_LINGER_MS - 1); + expect(fake.opened[0].close).not.toHaveBeenCalled(); + await advance(1); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + + // 닫은 뒤의 연결은 엔진 연결을 새로 연다 + await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); + + it(`ping 은 ${MAIL_EVENTS_PING_MS / 1000}초마다 — 엔진과 끊기면 바로 upstream: false 를 알리고 그동안의 ping 도 false, 다시 이으면 true`, async () => { + const watcher = await connected(ARA); + await advance(MAIL_EVENTS_PING_MS - 1); + expect(watcher.events).toHaveLength(1); + await advance(1); + expect(watcher.events.at(-1)).toEqual({ + type: 'ping', + data: { upstream: true }, + }); + + const { listener } = fake.opened[0]; + listener.upstream(false); + expect(watcher.events.at(-1)).toEqual({ + type: 'ping', + data: { upstream: false }, + }); + await advance(MAIL_EVENTS_PING_MS); + expect(watcher.events.at(-1)).toEqual({ + type: 'ping', + data: { upstream: false }, + }); + + // 끊긴 동안 들어온 연결은 처음 state 다음에 바로 알 수 있다 + const late = await connected(ARA); + expect(late.events).toEqual([ + { type: 'state', data: INITIAL }, + { type: 'ping', data: { upstream: false } }, + ]); + + listener.upstream(true); + for (const connection of [watcher, late]) { + expect(connection.events.at(-1)).toEqual({ + type: 'ping', + data: { upstream: true }, + }); + } + // 같은 값이면 다시 알리지 않는다 + const count = watcher.events.length; + listener.upstream(true); + expect(watcher.events).toHaveLength(count); + }); + + it(`액세스 토큰이 끝나기 ${TOKEN_EXPIRY_LEAD_MS / 1000}초 전에 end{token-expiring} 를 보내고 닫는다 — 다른 연결은 그대로, 남은 시간이 그보다 짧으면 토큰이 실제로 끝날 때`, async () => { + const expiring = await connected(ARA, NOW + 600_000); + const longer = await connected(ARA, NOW + 3_600_000); + + await advance(600_000 - TOKEN_EXPIRY_LEAD_MS - 1); + expect(expiring.closed).toBe(false); + await advance(1); + expect(expiring.events.at(-1)).toEqual({ + type: 'end', + data: { reason: 'token-expiring' }, + }); + expect(expiring.closed).toBe(true); + expect(longer.closed).toBe(false); + + // 세션 끝 30초 안에서는 갱신한 토큰도 짧다 — 바로 끝내면 클라이언트가 갱신 → 다시 잇기를 쉬지 않고 되풀이한다 + const almostExpired = await connected(ARA, Date.now() + 10_000); + await advance(10_000 - 1); + // (허브의 ping 은 그사이 올 수 있다) + expect(almostExpired.events.map(({ type }) => type)).not.toContain('end'); + expect(almostExpired.closed).toBe(false); + await advance(1); + expect(almostExpired.events.at(-1)).toEqual({ + type: 'end', + data: { reason: 'token-expiring' }, + }); + expect(almostExpired.closed).toBe(true); + + // 리드보다 조금 길면 그만큼 뒤에 (31초 남음 → 1초 뒤) + const justLonger = await connected(ARA, Date.now() + 31_000); + await advance(999); + expect(justLonger.closed).toBe(false); + await advance(1); + expect(justLonger.closed).toBe(true); + }); + + it('토큰의 만료를 모르면 액세스 토큰의 가장 긴 수명(1시간) 뒤에 닫는다', async () => { + const unknown = await connected(ARA, null); + + await advance(3_600_000 - TOKEN_EXPIRY_LEAD_MS - 1); + expect(unknown.closed).toBe(false); + await advance(1); + expect(unknown.closed).toBe(true); + }); + + it('서버 종료 — 모든 연결에 end{server-shutdown} 을 보내고 닫고, 엔진 연결도 닫는다 — 그 뒤의 연결은 MAIL_001', async () => { + const a = await connected(ARA); + const b = await connected(DOYUN); + + service.beforeApplicationShutdown(); + + for (const connection of [a, b]) { + expect(connection.events.at(-1)).toEqual({ + type: 'end', + data: { reason: 'server-shutdown' }, + }); + expect(connection.closed).toBe(true); + } + expect(fake.opened.map(({ close }) => close.mock.calls.length)).toEqual([ + 1, 1, + ]); + await expect(connect(ARA).opening).rejects.toMatchObject({ + code: 'MAIL_001', + }); + }); + + it('엔진 연결을 여는 중에 서버가 종료되면 — 열린 것을 바로 닫고 기다리던 연결은 MAIL_001', async () => { + fake.holdNext(); + const waiting = connect(ARA); + await flush(); + + service.beforeApplicationShutdown(); + fake.release(); + + await expect(waiting.opening).rejects.toMatchObject({ code: 'MAIL_001' }); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + expect(waiting.connection.events).toEqual([]); + }); + + it('처음 엔진 연결이 실패하면 함께 기다린 연결이 모두 그 실패를 받는다(보낸 것 없음) — 다음 연결은 처음부터 다시 연다', async () => { + fake.holdNext(); + const a = connect(ARA); + const b = connect(ARA); + await flush(); + fake.fail( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + + await expect(a.opening).rejects.toMatchObject({ code: 'MAIL_001' }); + await expect(b.opening).rejects.toMatchObject({ code: 'MAIL_001' }); + expect(a.connection.events).toEqual([]); + + const retried = await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + expect(retried.events).toEqual([{ type: 'state', data: INITIAL }]); + }); + + it('엔진 연결을 기다리는 사이 클라이언트가 끊으면 아무것도 보내지 않고 자리를 돌려준다 — 남은 연결이 없으면 잠시 뒤 엔진 연결을 닫는다', async () => { + fake.holdNext(); + const gone = connect(ARA); + await flush(); + gone.connection.leave(); + fake.release(); + await gone.opening; + + expect(gone.connection.events).toEqual([]); + await advance(MAIL_EVENTS_LINGER_MS); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + }); + + it('계정을 더 쓸 수 없어 엔진 연결이 끝나면(ended) 모든 연결을 닫는다 — 다음 연결은 엔진 연결을 다시 연다', async () => { + const a = await connected(ARA); + const b = await connected(ARA); + + fake.opened[0].listener.ended(); + + expect(a.closed).toBe(true); + expect(b.closed).toBe(true); + // end 를 보내지 않는다 — 다시 이으면 원인 코드(AUTH_001 등)를 받는다 + expect(a.events).toEqual([{ type: 'state', data: INITIAL }]); + await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); + + it('한 연결에 보내지 못하면 그 연결만 닫는다 — 다른 연결은 그대로 받는다 (오류 로그)', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const healthy = await connected(ARA); + const broken = client(); + await service.open( + ARA, + { + send: (event) => { + if (event.type !== 'state' || broken.events.length > 0) { + throw new Error('socket gone'); + } + broken.events.push(event); + return true; + }, + close: () => broken.sink.close(), + }, + { tokenExpiresAt: NOW + 3_600_000, signal: broken.signal }, + ); + + fake.opened[0].listener.newMail(NEW_MAIL); + + expect(healthy.events.at(-1)).toEqual({ type: 'new-mail', data: NEW_MAIL }); + expect(broken.closed).toBe(true); + expect(errorSpy).toHaveBeenCalledTimes(1); + // 닫힌 연결은 상한에서 빠졌다 + for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS - 1; i += 1) { + await connected(ARA); + } + }); + + it('처리기에 닿기 전에 끊긴 연결(signal 이 이미 끊김)은 자리를 잡지도 엔진 연결을 열지도 않는다', async () => { + const gone = client(); + gone.leave(); + + await service.open(ARA, gone.sink, { + tokenExpiresAt: NOW + 3_600_000, + signal: gone.signal, + }); + + expect(fake.watchMail).not.toHaveBeenCalled(); + expect(gone.events).toEqual([]); + }); + + it("응답이 이미 끊겨 보내기가 false 면('close' 가 오지 않은 응답) 그 연결을 빼고 자리를 돌려준다 — 오류 로그 없이, 남은 연결이 없으면 잠시 뒤 엔진 연결을 닫는다", async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const ghost = client(); + ghost.die(); + + await service.open(ARA, ghost.sink, { + tokenExpiresAt: NOW + 3_600_000, + signal: ghost.signal, + }); + + expect(ghost.closed).toBe(true); + expect(errorSpy).not.toHaveBeenCalled(); + // 자리를 돌려받았다 — 5개를 다 열 수 있다 + for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS; i += 1) { + (await connected(DOYUN)).leave(); + } + await advance(MAIL_EVENTS_LINGER_MS); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + }); + + it('열려 있다가 끊긴 응답은 다음에 보낼 때(소식 · ping) 빠진다 — 그 자리를 다시 쓸 수 있다', async () => { + const open = await Promise.all( + Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () => connected(ARA)), + ); + await expect(connect(ARA).opening).rejects.toMatchObject({ + code: 'REQ_001', + }); + + open[0].die(); + await advance(MAIL_EVENTS_PING_MS); + + expect(open[0].closed).toBe(true); + const again = await connected(ARA); + expect(again.events).toEqual([{ type: 'state', data: INITIAL }]); + }); + + it('보내다 난 오류의 로그에는 그 요청의 추적 ID 가 붙는다', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const broken = client(); + + await runWithRequestContext({ requestId: 'trace-send' }, () => + service.open( + ARA, + { + send: () => { + throw new Error('socket gone'); + }, + close: () => broken.sink.close(), + }, + { tokenExpiresAt: NOW + 3_600_000, signal: broken.signal }, + ), + ); + + expect(String(errorSpy.mock.calls[0]?.[0])).toMatch( + /^\[SYS_001\]\[trace-send\] /, + ); + expect(broken.closed).toBe(true); + }); + + it('엔진 연결이 끝날 때(ended) 응답 닫기가 던져도 나머지 연결 · 허브는 정리된다', async () => { + const throwing = client(); + await service.open( + ARA, + { + send: (event) => throwing.sink.send(event), + close: () => { + throw new Error('already closed'); + }, + }, + { tokenExpiresAt: NOW + 3_600_000, signal: throwing.signal }, + ); + const other = await connected(ARA); + + expect(() => fake.opened[0].listener.ended()).not.toThrow(); + expect(other.closed).toBe(true); + await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts new file mode 100644 index 0000000..6730fa2 --- /dev/null +++ b/backend/src/modules/mail/mail-events.service.ts @@ -0,0 +1,390 @@ +import { + BeforeApplicationShutdown, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants'; +import { + runOutsideRequestContext, + traceTag, +} from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; +import type { + MailLiveState, + MailWatch, + MailWatchListener, + NewMailNotice, +} from './mail.types'; + +/** 한 직원의 연결 상한 — 브라우저마다 하나라 기기 · 브라우저 5개 (계약 2절 "실시간 알림"). 서버 인스턴스마다 센다 */ +export const MAX_MAIL_EVENT_CONNECTIONS = 5; +/** 클라이언트 연결 유지(ping) 간격 */ +export const MAIL_EVENTS_PING_MS = 25_000; +/** 마지막 연결이 닫힌 뒤 엔진 연결을 남겨 두는 시간 — 새로 고침에 다시 열지 않게 */ +export const MAIL_EVENTS_LINGER_MS = 30_000; +/** + * 액세스 토큰이 끝나기 이만큼 전에 `end {token-expiring}` 를 보내고 닫는다 — 클라이언트가 세션을 갱신하고 다시 잇는다. + * 남은 시간이 이보다 짧으면 끝날 때 보낸다 (expiryDelay) + */ +export const TOKEN_EXPIRY_LEAD_MS = 30_000; +/** 토큰의 만료를 알 수 없을 때 연결을 닫는 시간 — 액세스 토큰의 가장 긴 수명 (env 검증의 상한) */ +const FALLBACK_CONNECTION_LIFETIME_MS = ACCESS_TTL_MAX_SECONDS * 1000; + +/** 서버가 곧 닫는 까닭 (`end` 이벤트) */ +export type MailEventsEndReason = 'token-expiring' | 'server-shutdown'; + +/** 실시간 알림의 이벤트 (계약 2절 "실시간 알림") — type 이 SSE 의 event 이름이다 */ +export type MailEvent = + | { readonly type: 'state'; readonly data: MailLiveState } + | { readonly type: 'new-mail'; readonly data: NewMailNotice } + | { readonly type: 'ping'; readonly data: { readonly upstream: boolean } } + | { + readonly type: 'end'; + readonly data: { readonly reason: MailEventsEndReason }; + }; + +/** 클라이언트 연결 하나 — 컨트롤러가 HTTP 응답으로 만든다 (어느 것도 던지지 않아야 한다) */ +export interface MailEventSink { + /** + * 이벤트 하나를 보낸다 — 처음 보낼 때 응답 머리를 쓴다. + * 이미 끊긴 응답이면 쓰지 않고 false — 서비스가 그 연결을 빼고 자리를 돌려준다 + * ('close' 를 듣기 전에 끊긴 응답은 그 신호가 오지 않으므로 이것이 두 번째 그물이다) + */ + send(event: MailEvent): boolean; + /** 응답을 끝낸다 */ + close(): void; +} + +export interface MailEventsOpenOptions { + /** 액세스 토큰이 끝나는 시각(epoch ms) — 모르면 null (액세스 토큰의 가장 긴 수명 뒤에 닫는다) */ + readonly tokenExpiresAt: number | null; + /** 클라이언트가 끊으면 abort — 기다리는 중이든 연결한 뒤든 바로 정리한다 */ + readonly signal: AbortSignal; +} + +/** + * `end {token-expiring}` 를 보낼 때까지 — 토큰이 끝나기 TOKEN_EXPIRY_LEAD_MS 전. + * 남은 시간이 그보다 짧으면(세션 끝 30초 안에서는 갱신한 토큰도 그렇다 — 액세스 토큰 수명은 세션 남은 시간을 넘지 않는다) + * 토큰이 실제로 끝날 때 보낸다. 바로 보내면 클라이언트가 갱신 → 다시 잇기를 쉬지 않고 되풀이한다 — + * 끝날 때 보내면 세션 끝에서는 갱신이 401 로 실패해 정상적인 세션 만료로 끝난다. + * 만료를 모르면 액세스 토큰의 가장 긴 수명을 남은 시간으로 본다 + */ +function expiryDelay(tokenExpiresAt: number | null): number { + const expiresAt = + tokenExpiresAt ?? Date.now() + FALLBACK_CONNECTION_LIFETIME_MS; + const remaining = Math.max(0, expiresAt - Date.now()); + return remaining > TOKEN_EXPIRY_LEAD_MS + ? remaining - TOKEN_EXPIRY_LEAD_MS + : remaining; +} + +interface Client { + readonly sink: MailEventSink; + expiryTimer: NodeJS.Timeout | null; + removed: boolean; +} + +/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 */ +interface Hub { + readonly user: string; + readonly clients: Set; + /** 엔진 연결을 기다리는 연결 수 — 연결 상한에 함께 센다 */ + pending: number; + watch: MailWatch | null; + /** 엔진 연결을 여는 중 — 기다리는 연결이 함께 기다린다 */ + starting: Promise | null; + /** 마지막으로 알게 된 상태 — 나중에 들어온 연결의 처음 state */ + live: MailLiveState | null; + /** 엔진과 이어져 있다 — ping 의 upstream */ + upstream: boolean; + pingTimer: NodeJS.Timeout | null; + lingerTimer: NodeJS.Timeout | null; + /** 닫혀 더 쓰지 않는다 (맵에서도 빠졌다) */ + closed: boolean; +} + +/** + * 실시간 알림의 연결 관리 (계약 2절 "실시간 알림") — 메일 엔진을 모른다. + * + * - 직원마다 엔진 연결 하나(MailProvider.watchMail)를 그 직원의 모든 연결에 나눈다 — 상태 · 새 메일 계산은 직원마다 한 번이다 + * - 첫 연결 때 열고, 마지막 연결이 닫히고 MAIL_EVENTS_LINGER_MS 뒤 닫는다. 한 직원 MAX_MAIL_EVENT_CONNECTIONS 개 — 넘으면 REQ_001 + * - 연결마다: 처음 state, 그 뒤 state · new-mail, MAIL_EVENTS_PING_MS 마다 ping(엔진과 끊긴 동안 upstream: false — 바뀐 때는 바로 한 번 더), + * 액세스 토큰이 끝나기 TOKEN_EXPIRY_LEAD_MS 전에(남은 시간이 그보다 짧으면 끝날 때) end{token-expiring}. + * 응답이 이미 끊겼으면(보내기가 false) 그 연결을 빼고 자리를 돌려준다 + * - 서버 종료(beforeApplicationShutdown — HTTP 서버를 닫기 전)에 모든 연결에 end{server-shutdown} 을 보내고 닫는다 — + * 오래 열린 연결이 종료를 붙잡지 않게 + * - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름) + * + * ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나), + * 다만 한 직원이 인스턴스마다 5개까지 열 수 있다. + */ +@Injectable() +export class MailEventsService implements BeforeApplicationShutdown { + private readonly logger = new Logger(MailEventsService.name); + private readonly hubs = new Map(); + private shuttingDown = false; + + constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {} + + /** + * 연결 하나를 연다 — 처음 state 를 보낸 뒤부터 소식을 보낸다. 돌아온 뒤로 연결은 `signal` 이 끊길 때까지 이어진다. + * 처음 state 를 보내기 전의 실패는 던진다 — 그때는 아무것도 보내지 않았다 (컨트롤러가 표준 실패 JSON 으로 답한다): + * 연결 상한 REQ_001, 처음 엔진 연결 실패 MAIL_001 · AUTH_001 · REQ_001, 서버 종료 중 MAIL_001 + */ + async open( + user: string, + sink: MailEventSink, + options: MailEventsOpenOptions, + ): Promise { + // 처리기에 닿기 전에 이미 끊겼다 — 자리를 잡지도, 엔진 연결을 열지도 않는다 + if (options.signal.aborted) return; + if (this.shuttingDown) throw this.shuttingDownFailure(); + + const hub = this.hubFor(user); + if (hub.clients.size + hub.pending >= MAX_MAIL_EVENT_CONNECTIONS) { + throw new BusinessException('REQ_001', { + context: { + reason: 'mail-events-connection-limit', + user: maskEmail(user), + limit: MAX_MAIL_EVENT_CONNECTIONS, + }, + }); + } + + hub.pending += 1; + this.cancelLinger(hub); + try { + await this.ensureWatch(hub); + } catch (error) { + hub.pending -= 1; + // 엔진 연결을 열지 못했다 — 함께 기다린 연결이 모두 끝나면 허브를 버린다 (다음 연결이 처음부터 다시 연다) + if ( + !hub.watch && + !hub.starting && + hub.clients.size === 0 && + hub.pending === 0 + ) { + this.closeHub(hub); + } + throw error; + } + hub.pending -= 1; + + if (this.shuttingDown) throw this.shuttingDownFailure(); + if (hub.closed || !hub.watch || !hub.live) { + // 기다리는 사이 엔진 연결이 끝났다 (계정을 더 쓸 수 없음 등) — 클라이언트는 다시 이으며 원인 코드를 받는다 + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-watch-ended', user: maskEmail(user) }, + }); + } + if (options.signal.aborted) { + // 기다리는 사이 클라이언트가 끊었다 + this.lingerIfIdle(hub); + return; + } + + const client: Client = { sink, expiryTimer: null, removed: false }; + hub.clients.add(client); + this.ensurePing(hub); + this.send(hub, client, { type: 'state', data: hub.live }); + if (!hub.upstream) { + this.send(hub, client, { type: 'ping', data: { upstream: false } }); + } + // 처음 보내기에서 이미 끊긴 응답이었다 (send 가 닫았다) + if (client.removed) return; + + client.expiryTimer = setTimeout( + () => this.end(hub, client, 'token-expiring'), + expiryDelay(options.tokenExpiresAt), + ); + client.expiryTimer.unref(); + options.signal.addEventListener('abort', () => this.remove(hub, client), { + once: true, + }); + } + + /** 서버 종료 — HTTP 서버를 닫기 전에 모든 연결을 end{server-shutdown} 으로 닫고 엔진 연결도 닫는다 */ + beforeApplicationShutdown(): void { + this.shuttingDown = true; + for (const hub of [...this.hubs.values()]) { + for (const client of [...hub.clients]) { + this.end(hub, client, 'server-shutdown'); + } + this.closeHub(hub); + } + } + + private shuttingDownFailure(): BusinessException { + return new BusinessException('MAIL_001', { + context: { reason: 'server-shutting-down' }, + }); + } + + private hubFor(user: string): Hub { + const existing = this.hubs.get(user); + if (existing) return existing; + const hub: Hub = { + user, + clients: new Set(), + pending: 0, + watch: null, + starting: null, + live: null, + upstream: true, + pingTimer: null, + lingerTimer: null, + closed: false, + }; + this.hubs.set(user, hub); + return hub; + } + + /** + * 엔진 연결을 연다 (이미 열렸으면 그대로) — 여는 중이면 함께 기다린다. 실패하면 기다리던 연결이 모두 그 실패를 받는다. + * 요청 컨텍스트 밖에서 연다 — 엔진 연결은 여러 요청이 함께 쓰므로 처음 요청의 추적 ID 를 물려받지 않게 + */ + private ensureWatch(hub: Hub): Promise { + if (hub.watch) return Promise.resolve(); + hub.starting ??= runOutsideRequestContext(() => + this.provider.watchMail(hub.user, this.listenerFor(hub)), + ) + .then((watch) => { + if (this.shuttingDown || hub.closed) { + // 여는 사이 서버 종료가 시작됐다 — 열린 것을 바로 닫는다 + watch.close(); + return; + } + hub.watch = watch; + // 여는 사이 온 소식이 있으면 그것이 더 새롭다 + hub.live ??= watch.initial; + }) + .finally(() => { + hub.starting = null; + }); + return hub.starting; + } + + /** 엔진 연결이 알리는 것을 그 직원의 모든 연결에 나눈다 */ + private listenerFor(hub: Hub): MailWatchListener { + return { + state: (live) => { + hub.live = live; + this.broadcast(hub, { type: 'state', data: live }); + }, + newMail: (notice) => + this.broadcast(hub, { type: 'new-mail', data: notice }), + upstream: (connected) => { + if (hub.upstream === connected) return; + hub.upstream = connected; + // 바뀐 때는 다음 ping 을 기다리지 않고 바로 알린다 — 클라이언트가 1분 확인으로 빨리 돌아가게 + this.broadcast(hub, { type: 'ping', data: { upstream: connected } }); + }, + ended: () => { + // 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다 + hub.watch = null; + for (const client of [...hub.clients]) this.closeClient(hub, client); + this.closeHub(hub); + }, + }; + } + + private broadcast(hub: Hub, event: MailEvent): void { + for (const client of [...hub.clients]) this.send(hub, client, event); + } + + /** + * 한 연결에 보낸다 — 이미 끊긴 응답(false)이면 그 연결을 빼고 자리를 돌려준다(로그 없이 — 클라이언트가 떠난 것이다). + * 보내다 난 오류는 그 연결만 닫는다 — 다른 연결 · 엔진 연결의 흐름을 끊지 않게 + */ + private send(hub: Hub, client: Client, event: MailEvent): void { + let delivered: boolean; + try { + delivered = client.sink.send(event); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 실시간 알림을 보내지 못해 연결을 닫습니다: user=${maskEmail(hub.user)}, event=${event.type}`, + error instanceof Error ? error.stack : undefined, + ); + delivered = false; + } + if (!delivered) this.closeClient(hub, client); + } + + /** 서버가 곧 닫는다고 알리고 닫는다 */ + private end(hub: Hub, client: Client, reason: MailEventsEndReason): void { + if (client.removed) return; + this.send(hub, client, { type: 'end', data: { reason } }); + this.closeClient(hub, client); + } + + private closeClient(hub: Hub, client: Client): void { + if (client.removed) return; + try { + client.sink.close(); + } catch { + // 이미 끊긴 응답 — 정리만 한다 + } + this.remove(hub, client); + } + + /** 연결을 뺀다 (몇 번 불러도 한 번만) — 남은 연결이 없으면 엔진 연결을 잠시 남겼다 닫는다 */ + private remove(hub: Hub, client: Client): void { + if (client.removed) return; + client.removed = true; + if (client.expiryTimer) clearTimeout(client.expiryTimer); + client.expiryTimer = null; + hub.clients.delete(client); + this.lingerIfIdle(hub); + } + + private lingerIfIdle(hub: Hub): void { + if ( + hub.closed || + hub.clients.size > 0 || + hub.pending > 0 || + hub.lingerTimer + ) { + return; + } + hub.lingerTimer = runOutsideRequestContext(() => + setTimeout(() => this.closeHub(hub), MAIL_EVENTS_LINGER_MS), + ); + hub.lingerTimer.unref(); + } + + private cancelLinger(hub: Hub): void { + if (hub.lingerTimer) clearTimeout(hub.lingerTimer); + hub.lingerTimer = null; + } + + private ensurePing(hub: Hub): void { + if (hub.pingTimer) return; + hub.pingTimer = runOutsideRequestContext(() => + setInterval( + () => + this.broadcast(hub, { + type: 'ping', + data: { upstream: hub.upstream }, + }), + MAIL_EVENTS_PING_MS, + ), + ); + hub.pingTimer.unref(); + } + + /** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */ + private closeHub(hub: Hub): void { + if (hub.closed) return; + hub.closed = true; + this.cancelLinger(hub); + if (hub.pingTimer) clearInterval(hub.pingTimer); + hub.pingTimer = null; + hub.watch?.close(); + hub.watch = null; + if (this.hubs.get(hub.user) === hub) this.hubs.delete(hub.user); + } +} diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 19dbeae..315a82d 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -13,6 +13,8 @@ import { DraftController } from './draft.controller'; import { JmapClient } from './jmap/jmap.client'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; import { MailAttachmentController } from './mail-attachment.controller'; +import { MailEventsController } from './mail-events.controller'; +import { MailEventsService } from './mail-events.service'; import { MailUploadTempCleanup, mailUploadStorage, @@ -104,6 +106,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailboxController, MailQuotaController, MailStateController, + MailEventsController, MailVacationController, MessageController, MessageDeletionController, @@ -120,6 +123,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { JmapClient, { provide: MAIL_PROVIDER, useClass: JmapMailProvider }, MailService, + // 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown) + MailEventsService, RecipientSuggestionService, MailUploadTempCleanup, ], diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 8702424..d83909c 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -12,6 +12,8 @@ import type { MailState, MailUsage, MailVacation, + MailWatch, + MailWatchListener, MessageDeletionResult, MessageDetailSource, MessageListQuery, @@ -119,6 +121,17 @@ export interface MailProvider { */ getMailState(user: string): Promise; + /** + * 실시간 알림 (계약 2절 "실시간 알림") — 그 직원의 메일함 · 메일 변화를 엔진에서 받는 연결 하나를 연다. + * 처음 상태(state — getMailState 와 같은 값 · 받은편지함의 안 읽은 수)를 잡은 뒤 돌려준다 — + * 그 전의 실패는 그 코드로 던지고 아무것도 남기지 않는다 (엔진에 연결할 수 없음 MAIL_001 · 계정을 쓸 수 없음 AUTH_001 · 엔진 한도 REQ_001). + * 그 뒤로는 `listener` 로 알린다 — 바뀐 상태(엔진 신호를 모아서 · 신호가 없어도 몇 분에 한 번 확인), + * 새 메일(받은편지함 · 초안 아님 · 읽지 않음 · 본인이 보내지 않음 · 변화를 본 시각보다 오래전에 받지 않음), + * 엔진 연결이 끊김 · 다시 이음, 끝남(계정을 더 쓸 수 없음). 끊기면 스스로 다시 잇고, 다시 이으면 지난 상태와 비교해 달라진 것을 알린다. + * `close()` 까지 이어진다. 한 직원에게 여러 개를 열지 않는다 — 여러 연결이 나눠 쓰는 일은 서비스(MailEventsService)가 한다. + */ + watchMail(user: string, listener: MailWatchListener): Promise; + /** * 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색). * `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001. diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index e9d834d..3331c79 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -67,6 +67,7 @@ function fakeProvider(): ProviderMocks { getAttachmentsTotalLimit: mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024), getMailState: mockOf<'getMailState'>(), + watchMail: mockOf<'watchMail'>(), listMessages: mockOf<'listMessages'>(), getMessage: mockOf<'getMessage'>(), updateMessage: mockOf<'updateMessage'>(), diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index add15fc..443c6ea 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -227,6 +227,56 @@ export interface MailState { readonly state: string; } +/** 실시간 알림의 상태 (계약 2절 "실시간 알림"의 `state` 이벤트) */ +export interface MailLiveState { + /** GET /api/mail-state 의 state 와 같은 값 (같은 함수로 만든다) */ + readonly state: string; + /** 받은편지함의 안 읽은 수 */ + readonly inboxUnread: number; +} + +/** 받은편지함에 새로 배달된 메일 한 통 (`new-mail` 이벤트) — 주소 · 제목은 메일 목록과 같은 규칙 */ +export interface NewMailMessage { + readonly id: string; + /** 보낸 사람 — 이름이 없으면 name 은 null, 주소가 없는 메일이면 null (목록의 from 과 같다) */ + readonly from: MailAddress | null; + /** 제목이 없으면 빈 문자열 */ + readonly subject: string; + /** ISO 8601 UTC */ + readonly receivedAt: string; +} + +/** 새 메일 소식 (`new-mail` 이벤트) */ +export interface NewMailNotice { + /** 알릴 메일 전체 수 */ + readonly count: number; + /** 받은 시각 새것부터 최대 5통 */ + readonly messages: NewMailMessage[]; +} + +/** + * 메일 엔진 연결(MailProvider.watchMail)이 알리는 것 — 엔진을 모르는 서비스(MailEventsService)가 받는다. + * 어느 것도 던지지 않아야 한다 (엔진 연결의 흐름 안에서 부른다) + */ +export interface MailWatchListener { + /** 메일함 · 메일이 바뀌었다 — 바뀐 뒤의 상태 (처음 상태는 watchMail 이 돌려준다) */ + state(state: MailLiveState): void; + /** 받은편지함에 새로 배달된 메일 — 같은 변화의 state 다음에 온다 */ + newMail(notice: NewMailNotice): void; + /** 엔진 연결이 끊겨 다시 잇는 중(false) · 다시 이었다(true) */ + upstream(connected: boolean): void; + /** 더 이을 수 없어 끝났다 (계정을 더 쓸 수 없음 — AUTH_001) — 이 뒤로는 아무것도 오지 않는다 */ + ended(): void; +} + +/** 열린 메일 엔진 연결 (MailProvider.watchMail) */ +export interface MailWatch { + /** 연결하며 엔진에 물은 처음 상태 */ + readonly initial: MailLiveState; + /** 엔진 연결을 닫는다 — 이 뒤로는 listener 를 부르지 않는다 */ + close(): void; +} + /** 여러 통을 바꾼 결과 (PATCH /api/messages) — 요청한 id 는 둘 중 한 곳에만 있다 */ export interface MessagesPatchResult { readonly updatedIds: string[]; diff --git a/backend/src/shared/logger/request-context.spec.ts b/backend/src/shared/logger/request-context.spec.ts index 7640bdd..87d60ac 100644 --- a/backend/src/shared/logger/request-context.spec.ts +++ b/backend/src/shared/logger/request-context.spec.ts @@ -1,5 +1,6 @@ import { currentRequestId, + runOutsideRequestContext, runWithRequestContext, traceTag, } from './request-context'; @@ -34,4 +35,19 @@ describe('요청 컨텍스트', () => { 'b', ]); }); + + it('요청 안에서 컨텍스트 밖으로 시작한 작업은 그 요청의 추적 ID 를 물려받지 않는다 — 요청은 그대로 이어진다', async () => { + const seen = await runWithRequestContext( + { requestId: 'trace-2' }, + async () => { + const detached = runOutsideRequestContext(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return currentRequestId(); + }); + return { detached: await detached, request: currentRequestId() }; + }, + ); + + expect(seen).toEqual({ detached: undefined, request: 'trace-2' }); + }); }); diff --git a/backend/src/shared/logger/request-context.ts b/backend/src/shared/logger/request-context.ts index e1c6ca5..74e8b2e 100644 --- a/backend/src/shared/logger/request-context.ts +++ b/backend/src/shared/logger/request-context.ts @@ -21,6 +21,15 @@ export function runWithRequestContext( return storage.run(context, callback); } +/** + * 요청 컨텍스트 **밖에서** `callback` 과 그 뒤의 비동기 흐름을 실행한다. + * 여러 요청이 함께 쓰는 오래 사는 작업(실시간 알림의 메일 엔진 연결 등)을 요청 안에서 시작할 때 쓴다 — + * 그렇지 않으면 그 작업의 로그가 처음 시작한 요청의 추적 ID 를 계속 달고 다닌다. + */ +export function runOutsideRequestContext(callback: () => T): T { + return storage.exit(callback); +} + /** 지금 처리 중인 요청의 추적 ID (요청 밖이면 undefined) */ export function currentRequestId(): string | undefined { return storage.getStore()?.requestId; diff --git a/backend/src/shared/utils/sse.spec.ts b/backend/src/shared/utils/sse.spec.ts new file mode 100644 index 0000000..f0fa65f --- /dev/null +++ b/backend/src/shared/utils/sse.spec.ts @@ -0,0 +1,113 @@ +import { SseFormatError, SseParser, formatSseEvent } from './sse'; + +describe('formatSseEvent', () => { + it('event · data(JSON 한 줄) · 빈 줄로 만든다', () => { + expect( + formatSseEvent('state', { state: 'c2Iy.c2Fq', inboxUnread: 3 }), + ).toBe('event: state\ndata: {"state":"c2Iy.c2Fq","inboxUnread":3}\n\n'); + }); + + it('값 안의 줄바꿈은 JSON 이스케이프로 남아 한 줄이다 — 다른 필드 · 이벤트가 끼어들지 못한다', () => { + const text = formatSseEvent('new-mail', { + subject: '제목\r\n\r\nevent: end\ndata: {}', + }); + + expect(text.split('\n')).toEqual([ + 'event: new-mail', + 'data: {"subject":"제목\\r\\n\\r\\nevent: end\\ndata: {}"}', + '', + '', + ]); + }); + + it('이벤트 이름에 줄바꿈 · 공백 · 콜론이 있으면 만들지 않는다', () => { + for (const name of ['state\ndata: x', 'new mail', 'a:b', '']) { + expect(() => formatSseEvent(name, {})).toThrow(); + } + }); + + it('만든 것을 해석기로 다시 읽으면 같은 이름 · 데이터다', () => { + const parser = new SseParser(); + const data = { count: 2, messages: [{ subject: '줄\n바꿈' }] }; + + expect(parser.push(formatSseEvent('new-mail', data))).toEqual([ + { event: 'new-mail', data: JSON.stringify(data) }, + ]); + }); +}); + +describe('SseParser', () => { + it('메일 엔진(Stalwart)의 state · ping 이벤트를 읽는다', () => { + const parser = new SseParser(); + + expect( + parser.push( + 'event: state\ndata: {"@type":"StateChange","changed":{"d":{"Email":"s1"}}}\n\n' + + 'event: ping\ndata: {"interval": 30}\n\n', + ), + ).toEqual([ + { + event: 'state', + data: '{"@type":"StateChange","changed":{"d":{"Email":"s1"}}}', + }, + { event: 'ping', data: '{"interval": 30}' }, + ]); + }); + + it('조각 사이에서 끊긴 줄 · 이벤트를 이어서 읽는다 (CRLF 의 CR 과 LF 가 다른 조각이어도)', () => { + const parser = new SseParser(); + const text = + 'event: state\r\ndata: {"a":1}\r\n\r\nevent: ping\r\ndata: {}\r\n\r\n'; + const messages = [...text].flatMap((char) => parser.push(char)); + + expect(messages).toEqual([ + { event: 'state', data: '{"a":1}' }, + { event: 'ping', data: '{}' }, + ]); + }); + + it('줄 끝은 LF · CRLF · CR 모두 받는다', () => { + for (const lineBreak of ['\n', '\r\n', '\r']) { + const parser = new SseParser(); + expect( + parser.push(`event: x${lineBreak}data: 1${lineBreak}${lineBreak}`), + ).toEqual([{ event: 'x', data: '1' }]); + } + }); + + it('data 여러 줄은 줄바꿈으로 잇고, 이름이 없으면 message · 콜론 뒤 공백은 하나만 뗀다', () => { + const parser = new SseParser(); + + expect(parser.push('data: 두 칸\ndata:붙임\ndata\n\n')).toEqual([ + { event: 'message', data: ' 두 칸\n붙임\n' }, + ]); + }); + + it('주석 · id · retry 와 data 없는 이벤트는 내보내지 않는다 — 맨 앞 BOM 도 뗀다', () => { + const parser = new SseParser(); + + expect( + parser.push(': 연결 유지\nretry: 1000\nid: 7\n\nevent: empty\n\n'), + ).toEqual([]); + expect(parser.push('data: 다음\n\n')).toEqual([ + { event: 'message', data: '다음' }, + ]); + }); + + it('끝나지 않은 이벤트는 내보내지 않고 기다린다', () => { + const parser = new SseParser(); + + expect(parser.push('event: state\ndata: {"a"')).toEqual([]); + expect(parser.push(':1}\n')).toEqual([]); + expect(parser.push('\n')).toEqual([{ event: 'state', data: '{"a":1}' }]); + }); + + it('한 줄이나 한 이벤트의 data 가 한도를 넘으면 SseFormatError — 줄바꿈 없이 끝없이 보내도 쌓아 두지 않는다', () => { + const longLine = new SseParser(10); + expect(() => longLine.push('data: 01234567890')).toThrow(SseFormatError); + + const longEvent = new SseParser(20); + expect(longEvent.push('data: 0123456789\n')).toEqual([]); + expect(() => longEvent.push('data: 0123456789\n')).toThrow(SseFormatError); + }); +}); diff --git a/backend/src/shared/utils/sse.ts b/backend/src/shared/utils/sse.ts new file mode 100644 index 0000000..016fee4 --- /dev/null +++ b/backend/src/shared/utils/sse.ts @@ -0,0 +1,146 @@ +/** + * Server-Sent Events(`text/event-stream` — WHATWG HTML "Server-sent events") 형식. + * + * - 쓰기(`formatSseEvent`): 실시간 알림(GET /api/mail-events)이 브라우저에 보내는 이벤트 — 순수 함수 + * - 읽기(`SseParser`): 메일 엔진의 JMAP EventSource(RFC 8620 7.3) — 받은 글자만 다루는 해석기(입출력 없음) + */ + +/** 이벤트 이름 — 줄바꿈이 들어가면 다른 필드가 끼어들 수 있어 영문 · 숫자 · `-` · `_` 만 받는다 */ +const EVENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** SSE 의 줄 끝 — CRLF · LF · CR 셋 다 (WHATWG 규칙) */ +const LINE_BREAK = /\r\n|\r|\n/; + +const LF = 0x0a; +const CR = 0x0d; + +/** 스트림 맨 앞에 올 수 있는 BOM — 해석하기 전에 뗀다 */ +const BYTE_ORDER_MARK = ''; + +/** + * 이벤트 하나를 만든다 — `event: <이름>` · `data: ` · 빈 줄. + * JSON 은 문자열 안의 줄바꿈을 `\n` 으로 적으므로 보통 한 줄이다 — 그래도 줄마다 `data:` 를 붙인다(형식 규칙). + */ +export function formatSseEvent(name: string, data: object): string { + if (!EVENT_NAME_PATTERN.test(name)) { + throw new Error(`SSE 이벤트 이름으로 쓸 수 없는 값입니다: ${name}`); + } + const lines = JSON.stringify(data).split(LINE_BREAK); + return `event: ${name}\n${lines.map((line) => `data: ${line}`).join('\n')}\n\n`; +} + +/** 상대가 SSE 형식을 지키지 않았다 (한 줄 · 한 이벤트가 너무 김) — 읽는 쪽이 연결을 닫는다 */ +export class SseFormatError extends Error { + override readonly name = 'SseFormatError'; +} + +/** 받은 이벤트 하나 */ +export interface SseMessage { + /** `event` 필드 — 없으면 `message` */ + readonly event: string; + /** `data` 줄들을 줄바꿈으로 이은 것 */ + readonly data: string; +} + +/** + * 한 줄 · 한 이벤트의 data 최대 길이 (JS 문자열 길이) — 상대가 줄바꿈 없이 끝없이 보내도 메모리를 붙잡지 않게. + * 메일 엔진의 StateChange 는 계정 하나에 수백 자다. + */ +export const SSE_MAX_FIELD_LENGTH = 1_048_576; + +/** 이벤트 이름이 없을 때의 이름 (WHATWG 규칙) */ +const DEFAULT_EVENT_NAME = 'message'; + +/** + * `text/event-stream` 해석기 — 디코딩한 글자를 받은 대로 넣으면 끝난 이벤트를 돌려준다. + * 줄이 조각 사이에서 끊겨도(CRLF 의 CR 과 LF 가 다른 조각이어도) 이어서 읽는다. + * 주석(`:` 로 시작) · `id` · `retry` 는 쓰지 않는다. data 가 없는 이벤트는 내보내지 않는다(WHATWG 규칙). + * 한 줄이나 한 이벤트의 data 가 `maxLength` 를 넘으면 SseFormatError — 읽는 쪽이 연결을 닫는다. + */ +export class SseParser { + /** 아직 줄 끝을 만나지 못한 글자 */ + private pending = ''; + /** 지난 조각이 CR 로 끝났다 — 다음 조각 맨 앞의 LF 는 같은 줄 끝이다 */ + private skipLeadingLineFeed = false; + private started = false; + private eventName = ''; + private dataLines: string[] = []; + private dataLength = 0; + + constructor(private readonly maxLength = SSE_MAX_FIELD_LENGTH) {} + + push(chunk: string): SseMessage[] { + let text = chunk; + if (!this.started && text !== '') { + this.started = true; + if (text.startsWith(BYTE_ORDER_MARK)) text = text.slice(1); + } + if (this.skipLeadingLineFeed && text !== '') { + this.skipLeadingLineFeed = false; + if (text.charCodeAt(0) === LF) text = text.slice(1); + } + + const messages: SseMessage[] = []; + let start = 0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code !== LF && code !== CR) continue; + + this.readLine(this.pending + text.slice(start, index), messages); + this.pending = ''; + if (code === CR) { + if (index + 1 < text.length) { + if (text.charCodeAt(index + 1) === LF) index += 1; + } else { + this.skipLeadingLineFeed = true; + } + } + start = index + 1; + } + + this.pending += text.slice(start); + if (this.pending.length > this.maxLength) { + throw new SseFormatError('SSE 한 줄이 너무 깁니다'); + } + return messages; + } + + private readLine(line: string, messages: SseMessage[]): void { + if (line.length > this.maxLength) { + throw new SseFormatError('SSE 한 줄이 너무 깁니다'); + } + if (line === '') { + this.dispatch(messages); + return; + } + // 주석 — 연결 유지용으로 보내는 서버가 있다 + if (line.startsWith(':')) return; + + const colon = line.indexOf(':'); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + + if (field === 'event') { + this.eventName = value; + } else if (field === 'data') { + this.dataLength += value.length + 1; + if (this.dataLength > this.maxLength) { + throw new SseFormatError('SSE 이벤트의 data 가 너무 깁니다'); + } + this.dataLines.push(value); + } + } + + private dispatch(messages: SseMessage[]): void { + if (this.dataLines.length > 0) { + messages.push({ + event: this.eventName || DEFAULT_EVENT_NAME, + data: this.dataLines.join('\n'), + }); + } + this.eventName = ''; + this.dataLines = []; + this.dataLength = 0; + } +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 81afe30..d84f641 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { createHash } from 'crypto'; import { existsSync, unlinkSync } from 'fs'; -import type { Server } from 'http'; +import { get as httpGet, type IncomingMessage, type Server } from 'http'; import { basename, join } from 'path'; import { Readable } from 'stream'; import request from 'supertest'; @@ -34,6 +34,8 @@ import type { MailboxCreate, MailboxDeletion, MailVacation, + MailWatch, + MailWatchListener, MessageDeletionResult, MessageListQuery, MessageSource, @@ -141,6 +143,9 @@ const TAKEN_MAILBOX_NAME = '이미 있는 이름'; /** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */ const SLOW_MAILBOX = 'slowbox'; +/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */ +const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test'; + /** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */ const NEVER_CONFIGURED_VACATION: MailVacation = { isEnabled: false, @@ -196,6 +201,53 @@ function listenOnAnyPort(server: Server): Promise { return new Promise((resolve) => server.listen(0, () => resolve())); } +/** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */ +interface EventStreamClient { + readonly response: IncomingMessage; + /** 지금까지 받은 글자 */ + received(): string; + /** 클라이언트가 끊는다 */ + close(): void; +} + +/** + * 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다. + * 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다 + */ +function openEventStream( + server: Server, + cookie: string, +): Promise { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return new Promise((resolve, reject) => { + const request = httpGet( + { + host: '127.0.0.1', + port, + path: '/api/mail-events', + headers: { cookie, accept: 'text/event-stream' }, + agent: false, + }, + (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + text += chunk; + }); + // 끊은 뒤의 오류(ECONNRESET 등)는 테스트와 상관없다 + response.on('error', () => undefined); + resolve({ + response, + received: () => text, + close: () => request.destroy(), + }); + }, + ); + request.on('error', reject); + }); +} + /** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */ function closeServer(server: Server): Promise { return new Promise((resolve, reject) => @@ -269,6 +321,13 @@ describe('응답 계약 (e2e)', () => { update: MessageUpdate; }[] = []; + /** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */ + const liveWatches: { + user: string; + listener: MailWatchListener; + closed: boolean; + }[] = []; + /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => jwt.sign(payload, { @@ -385,6 +444,28 @@ describe('응답 계약 (e2e)', () => { Promise.resolve(correspondents.get(user) ?? []), // 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다 getMailState: () => Promise.resolve({ state: MAIL_STATE }), + // 5단계 — 엔진 연결(다시 잇기 · 새 메일 계산)은 jmap-mail-watch.spec.ts · jmap-mail.provider.spec.ts 가 본다. + // 여기서는 처음 상태만 주고, 소식은 테스트가 listener 로 흘려 넣는다 + watchMail: ( + user: string, + listener: MailWatchListener, + ): Promise => { + if (user === EVENTS_ENGINE_DOWN) { + return Promise.reject( + new BusinessException('MAIL_001', { + context: { reason: 'e2e-engine-down' }, + }), + ); + } + const watch = { user, listener, closed: false }; + liveWatches.push(watch); + return Promise.resolve({ + initial: { state: MAIL_STATE, inboxUnread: 2 }, + close: () => { + watch.closed = true; + }, + }); + }, listMessages: (user: string, query: MessageListQuery) => { listQueries.push({ user, query }); return Promise.resolve({ @@ -935,6 +1016,7 @@ describe('응답 계약 (e2e)', () => { ], ['get', '/api/recipient-suggestions'], ['get', '/api/mail-state'], + ['get', '/api/mail-events'], ['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'], ['patch', '/api/messages'], ['post', '/api/message-deletions'], @@ -2443,6 +2525,127 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 5단계 — 실시간 알림 (계약 2절 "실시간 알림"). + * 엔진 연결(다시 잇기 · 무소식 감시 · 새 메일 계산)과 연결 관리(나눠 쓰기 · 30초 · 토큰 만료 · 종료)는 단위 테스트가 본다 — + * 여기서는 실제 HTTP 의 머리글 · 이벤트 형식 · 연결 전 실패의 표준 JSON 을 본다. + * 응답이 끝나지 않으므로 서버를 미리 열어 http.get 으로 읽는다 (로그인 동시 시도 테스트와 같은 이유로 테스트가 끝나면 닫는다) + */ + describe('5단계 — 실시간 알림 (GET /api/mail-events)', () => { + /** 받은 글자에 빈 줄로 끝난 이벤트가 n 개 쌓일 때까지 기다린다 */ + const eventsIn = (text: string) => + text.split('\n\n').filter((block) => block !== ''); + const waitForEvents = (stream: EventStreamClient, count: number) => + waitUntil( + () => + stream.received().endsWith('\n\n') && + eventsIn(stream.received()).length >= count, + GATE_DEADLINE_MS, + ); + + /** 서버를 미리 열고, 끝나면 연 연결을 모두 끊고 닫는다 */ + async function withListeningServer( + run: (httpServer: Server, opened: EventStreamClient[]) => Promise, + ): Promise { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + const opened: EventStreamClient[] = []; + try { + await run(httpServer, opened); + } finally { + for (const stream of opened) stream.close(); + if (startedServer) await closeServer(httpServer); + } + } + + it( + '200 text/event-stream — 머리글(charset · no-cache, no-transform · X-Accel-Buffering: no)과 처음 state, 그 뒤 엔진 소식을 이어서 보낸다', + async () => { + const ME = 'events.me@e2e.arasystem.test'; + await withListeningServer(async (httpServer, opened) => { + const stream = await openEventStream(httpServer, cookieOf(ME)); + opened.push(stream); + await waitForEvents(stream, 1); + + const { headers, statusCode } = stream.response; + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe( + 'text/event-stream; charset=utf-8', + ); + expect(headers['cache-control']).toBe('no-cache, no-transform'); + expect(headers['x-accel-buffering']).toBe('no'); + expect(typeof headers['x-request-id']).toBe('string'); + // 표준 래퍼 없이 — 상태 값은 GET /api/mail-state 와 같다 + expect(stream.received()).toBe( + `event: state\ndata: ${JSON.stringify({ state: MAIL_STATE, inboxUnread: 2 })}\n\n`, + ); + + const watch = liveWatches.find(({ user }) => user === ME); + expect(watch).toBeDefined(); + const newMail = { + count: 1, + messages: [ + { + id: 'm1', + from: { + name: '박서준', + email: 'seojun.park@e2e.arasystem.test', + }, + subject: '줄\n바꿈이 있는 제목', + receivedAt: '2026-10-28T05:42:00.000Z', + }, + ], + }; + watch?.listener.state({ state: 'c2Iz.c2Fr', inboxUnread: 3 }); + watch?.listener.newMail(newMail); + await waitForEvents(stream, 3); + + expect(eventsIn(stream.received()).slice(1)).toEqual([ + `event: state\ndata: ${JSON.stringify({ state: 'c2Iz.c2Fr', inboxUnread: 3 })}`, + `event: new-mail\ndata: ${JSON.stringify(newMail)}`, + ]); + }); + }, + GATED_TEST_TIMEOUT_MS, + ); + + it('처음 연결 때 엔진에 연결할 수 없으면 스트림을 열지 않고 503 MAIL_001 (표준 실패 JSON)', async () => { + const res = await request(server) + .get('/api/mail-events') + .set('Cookie', cookieOf(EVENTS_ENGINE_DOWN)) + .expect(503); + + expect(res.headers['content-type']).toMatch(/^application\/json/); + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe('MAIL_001'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + }); + + it( + '한 직원의 연결이 5개를 넘으면 429 REQ_001 (표준 실패 JSON) — 엔진 연결은 하나를 나눠 쓴다', + async () => { + const ME = 'events.limit@e2e.arasystem.test'; + await withListeningServer(async (httpServer, opened) => { + for (let i = 0; i < 5; i += 1) { + const stream = await openEventStream(httpServer, cookieOf(ME)); + opened.push(stream); + await waitForEvents(stream, 1); + expect(stream.response.statusCode).toBe(200); + } + + const res = await request(httpServer) + .get('/api/mail-events') + .set('Cookie', cookieOf(ME)) + .expect(429); + expect(body(res).error?.code).toBe('REQ_001'); + expect(liveWatches.filter(({ user }) => user === ME)).toHaveLength(1); + }); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). diff --git a/backend/test/fake-event-stream.ts b/backend/test/fake-event-stream.ts new file mode 100644 index 0000000..ab8a9c0 --- /dev/null +++ b/backend/test/fake-event-stream.ts @@ -0,0 +1,70 @@ +/** + * 메일 엔진의 변화 소식 스트림(JMAP EventSource) 대역 — 단위 테스트 공용. + * fetch 본문처럼 바이트를 흘려보내고, 읽는 쪽이 닫으면(abort) 읽던 자리에 오류를 낸다. + * 테스트가 `send` · `end` 로 엔진 쪽을 흉내 낸다. 타이머를 쓰지 않는다 (promise 로만 돈다 — 가짜 타이머와 함께 써도 된다). + */ +const encoder = new TextEncoder(); + +export class FakeEventStream { + /** 읽는 쪽이 닫았다 */ + aborted = false; + private readonly queue: (Uint8Array | Error | null)[] = []; + private wake: (() => void) | null = null; + + constructor(signal: AbortSignal) { + const abort = () => { + this.aborted = true; + this.push(new Error('aborted')); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + } + + /** 엔진이 글자를 보낸다 */ + send(text: string): void { + this.push(encoder.encode(text)); + } + + /** 엔진이 스트림을 닫는다 */ + end(): void { + this.push(null); + } + + async *body(): AsyncGenerator { + for (;;) { + while (this.queue.length === 0) { + await new Promise((resolve) => { + this.wake = resolve; + }); + } + const item = this.queue.shift(); + if (item === undefined || item === null) return; + if (item instanceof Error) throw item; + yield item; + } + } + + private push(item: Uint8Array | Error | null): void { + this.queue.push(item); + this.wake?.(); + this.wake = null; + } +} + +/** 이 계정의 메일 · 메일함 변화 신호 — Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 확인) */ +export function stateChangeEvent(accountId: string): string { + return `event: state\ndata: ${JSON.stringify({ + '@type': 'StateChange', + changed: { [accountId]: { Email: 'x', Mailbox: 'y' } }, + })}\n\n`; +} + +/** 엔진의 연결 유지 신호 */ +export const PING_EVENT = 'event: ping\ndata: {"interval": 30}\n\n'; + +/** 미뤄 둔 비동기 처리(스트림 읽기 · 엔진 묻기의 결과)를 마저 돌린다 — 타이머는 움직이지 않는다 (setImmediate 는 가짜로 두지 않는다) */ +export async function settleAsync(): Promise { + for (let round = 0; round < 10; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +}