feat(backend): 실시간 알림 GET /api/mail-events (엔진 EventSource 중계)

- 직원마다 엔진 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 13:32:08 +09:00
parent e27e9feb0b
commit f725908053
28 changed files with 4462 additions and 19 deletions
@@ -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' }),
+11 -1
View File
@@ -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 }
: {}),
};
}
}
@@ -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<JmapEventStream> => {
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<void> {
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<MailWatch> => {
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<JmapEventStream>((_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<WatchReading>((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<WatchReading>((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],
]);
});
});
@@ -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<string>;
}
/** 엔진에 물은 결과 — 지금 상태 · 다음 기준 · 새 메일(셀 수 없거나 없으면 null) */
export interface WatchReading {
readonly live: MailLiveState;
readonly position: WatchPosition;
readonly newMail: NewMailNotice | null;
}
/** 엔진 호출 — 어댑터(JmapMailProvider.watchMail)가 직원 주소를 묶어 넘긴다 */
export interface WatchEngine {
/** 변화 소식 스트림을 연다 (응답 머리까지 받으면 돌려준다) — `signal` 로 닫는다 */
open(signal: AbortSignal): Promise<JmapEventStream>;
/** 지금 상태와 기준을 처음 잡는다 (새 메일은 없다) — `timeoutMs` 안에 (세션 조회 포함, 없으면 기본 제한 시간) */
snapshot(timeoutMs?: number): Promise<WatchReading>;
/** 기준부터 바뀐 것 — 지금 상태 · 다음 기준 · 새 메일 */
changes(position: WatchPosition): Promise<WatchReading>;
}
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<void> | 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<MailWatch> {
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<MailLiveState> {
const connection = this.begin();
const deadline = Date.now() + WATCH_START_TIMEOUT_MS;
let expiry: NodeJS.Timeout | null = null;
const timedOut = new Promise<never>((_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<void> {
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<WatchReading> {
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<void> {
// 끊기기 전에 시작한 묻기가 있으면 끝난 뒤에 — 같은 기준으로 두 번 물어 새 메일을 두 번 알리지 않게
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);
}
}
@@ -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<string, unknown>();
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<string, unknown> = {}) => ({
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<void> {
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;
@@ -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<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
const emails = expectResult<JmapGetResult<JmapEmail>>(
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<JmapGetResult<JmapMailbox>>(
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<MailWatch> {
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<JmapGetResult<JmapEmail>>(
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<WatchReading> {
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<JmapIdentity>).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<WatchReading> {
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<WatchReading> {
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<JmapGetResult<JmapEmail>>(
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 에 쓴다),
* 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다
@@ -0,0 +1,51 @@
import { isMailStateChange } from './jmap-push';
/** Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 개발 엔진에서 받은 것) */
const stateChange = (changed: Record<string, Record<string, string>>) =>
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);
}
});
});
@@ -0,0 +1,47 @@
/**
* 메일 엔진의 변화 소식(JMAP push — RFC 8620 7) 해석 (순수 함수).
*
* Stalwart 0.16.27 의 EventSource (2026-10-28 개발 엔진에서 확인):
* - 변화: `event: state` / `data: {"@type":"StateChange","changed":{"<accountId>":{"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<string, unknown> {
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),
);
}
@@ -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<string, string>).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<Response>((_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));
+116 -1
View File
@@ -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<Uint8Array>;
}
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<JmapEventStream> {
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,
};
}
}
@@ -24,6 +24,8 @@ export interface JmapSessionResource {
apiUrl?: string;
downloadUrl?: string;
uploadUrl?: string;
/** 변화 소식 스트림 주소 템플릿 (RFC 8620 7.3) — `{types}` · `{closeafter}` · `{ping}` */
eventSourceUrl?: string;
primaryAccounts?: Record<string, string>;
/** 계정별 기능·한도 — 메일 기능의 `maxSizeAttachmentsPerEmail` 를 읽는다 */
accounts?: Record<
@@ -122,6 +124,17 @@ export interface JmapGetResult<T> {
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;
@@ -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> = {},
): 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();
});
});
+80
View File
@@ -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<string>,
): 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<string>,
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),
};
}
@@ -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<string, string>();
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([]);
});
});
@@ -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: <JSON 한 줄>` · 빈 줄. ' +
'이벤트: ' +
'`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<void> {
// 클라이언트가 끊으면(기다리는 중이든 연결한 뒤든) 바로 정리한다
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,
});
}
}
@@ -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<MailWatch> => {
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<MailWatch>((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<typeof client>;
async function flush(): Promise<void> {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
}
describe('MailEventsService (실시간 알림의 연결 관리)', () => {
let fake: ReturnType<typeof fakeProvider>;
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<Client> => {
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);
});
});
@@ -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<Client>;
/** 엔진 연결을 기다리는 연결 수 — 연결 상한에 함께 센다 */
pending: number;
watch: MailWatch | null;
/** 엔진 연결을 여는 중 — 기다리는 연결이 함께 기다린다 */
starting: Promise<void> | 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<string, Hub>();
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<void> {
// 처리기에 닿기 전에 이미 끊겼다 — 자리를 잡지도, 엔진 연결을 열지도 않는다
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<void> {
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);
}
}
+5
View File
@@ -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,
],
+13
View File
@@ -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<MailState>;
/**
* 실시간 알림 (계약 2절 "실시간 알림") — 그 직원의 메일함 · 메일 변화를 엔진에서 받는 연결 하나를 연다.
* 처음 상태(state — getMailState 와 같은 값 · 받은편지함의 안 읽은 수)를 잡은 뒤 돌려준다 —
* 그 전의 실패는 그 코드로 던지고 아무것도 남기지 않는다 (엔진에 연결할 수 없음 MAIL_001 · 계정을 쓸 수 없음 AUTH_001 · 엔진 한도 REQ_001).
* 그 뒤로는 `listener` 로 알린다 — 바뀐 상태(엔진 신호를 모아서 · 신호가 없어도 몇 분에 한 번 확인),
* 새 메일(받은편지함 · 초안 아님 · 읽지 않음 · 본인이 보내지 않음 · 변화를 본 시각보다 오래전에 받지 않음),
* 엔진 연결이 끊김 · 다시 이음, 끝남(계정을 더 쓸 수 없음). 끊기면 스스로 다시 잇고, 다시 이으면 지난 상태와 비교해 달라진 것을 알린다.
* `close()` 까지 이어진다. 한 직원에게 여러 개를 열지 않는다 — 여러 연결이 나눠 쓰는 일은 서비스(MailEventsService)가 한다.
*/
watchMail(user: string, listener: MailWatchListener): Promise<MailWatch>;
/**
* 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색).
* `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001.
@@ -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'>(),
+50
View File
@@ -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[];