generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
@@ -9,6 +9,11 @@ export interface AuthenticatedUser {
|
||||
readonly email: string;
|
||||
/** 표시 이름 */
|
||||
readonly name: string;
|
||||
/**
|
||||
* 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다.
|
||||
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
|
||||
*/
|
||||
readonly accessTokenExpiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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'>(),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
currentRequestId,
|
||||
runOutsideRequestContext,
|
||||
runWithRequestContext,
|
||||
traceTag,
|
||||
} from './request-context';
|
||||
@@ -34,4 +35,19 @@ describe('요청 컨텍스트', () => {
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('요청 안에서 컨텍스트 밖으로 시작한 작업은 그 요청의 추적 ID 를 물려받지 않는다 — 요청은 그대로 이어진다', async () => {
|
||||
const seen = await runWithRequestContext(
|
||||
{ requestId: 'trace-2' },
|
||||
async () => {
|
||||
const detached = runOutsideRequestContext(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return currentRequestId();
|
||||
});
|
||||
return { detached: await detached, request: currentRequestId() };
|
||||
},
|
||||
);
|
||||
|
||||
expect(seen).toEqual({ detached: undefined, request: 'trace-2' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,15 @@ export function runWithRequestContext<T>(
|
||||
return storage.run(context, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 컨텍스트 **밖에서** `callback` 과 그 뒤의 비동기 흐름을 실행한다.
|
||||
* 여러 요청이 함께 쓰는 오래 사는 작업(실시간 알림의 메일 엔진 연결 등)을 요청 안에서 시작할 때 쓴다 —
|
||||
* 그렇지 않으면 그 작업의 로그가 처음 시작한 요청의 추적 ID 를 계속 달고 다닌다.
|
||||
*/
|
||||
export function runOutsideRequestContext<T>(callback: () => T): T {
|
||||
return storage.exit(callback);
|
||||
}
|
||||
|
||||
/** 지금 처리 중인 요청의 추적 ID (요청 밖이면 undefined) */
|
||||
export function currentRequestId(): string | undefined {
|
||||
return storage.getStore()?.requestId;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { SseFormatError, SseParser, formatSseEvent } from './sse';
|
||||
|
||||
describe('formatSseEvent', () => {
|
||||
it('event · data(JSON 한 줄) · 빈 줄로 만든다', () => {
|
||||
expect(
|
||||
formatSseEvent('state', { state: 'c2Iy.c2Fq', inboxUnread: 3 }),
|
||||
).toBe('event: state\ndata: {"state":"c2Iy.c2Fq","inboxUnread":3}\n\n');
|
||||
});
|
||||
|
||||
it('값 안의 줄바꿈은 JSON 이스케이프로 남아 한 줄이다 — 다른 필드 · 이벤트가 끼어들지 못한다', () => {
|
||||
const text = formatSseEvent('new-mail', {
|
||||
subject: '제목\r\n\r\nevent: end\ndata: {}',
|
||||
});
|
||||
|
||||
expect(text.split('\n')).toEqual([
|
||||
'event: new-mail',
|
||||
'data: {"subject":"제목\\r\\n\\r\\nevent: end\\ndata: {}"}',
|
||||
'',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
it('이벤트 이름에 줄바꿈 · 공백 · 콜론이 있으면 만들지 않는다', () => {
|
||||
for (const name of ['state\ndata: x', 'new mail', 'a:b', '']) {
|
||||
expect(() => formatSseEvent(name, {})).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('만든 것을 해석기로 다시 읽으면 같은 이름 · 데이터다', () => {
|
||||
const parser = new SseParser();
|
||||
const data = { count: 2, messages: [{ subject: '줄\n바꿈' }] };
|
||||
|
||||
expect(parser.push(formatSseEvent('new-mail', data))).toEqual([
|
||||
{ event: 'new-mail', data: JSON.stringify(data) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SseParser', () => {
|
||||
it('메일 엔진(Stalwart)의 state · ping 이벤트를 읽는다', () => {
|
||||
const parser = new SseParser();
|
||||
|
||||
expect(
|
||||
parser.push(
|
||||
'event: state\ndata: {"@type":"StateChange","changed":{"d":{"Email":"s1"}}}\n\n' +
|
||||
'event: ping\ndata: {"interval": 30}\n\n',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
event: 'state',
|
||||
data: '{"@type":"StateChange","changed":{"d":{"Email":"s1"}}}',
|
||||
},
|
||||
{ event: 'ping', data: '{"interval": 30}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('조각 사이에서 끊긴 줄 · 이벤트를 이어서 읽는다 (CRLF 의 CR 과 LF 가 다른 조각이어도)', () => {
|
||||
const parser = new SseParser();
|
||||
const text =
|
||||
'event: state\r\ndata: {"a":1}\r\n\r\nevent: ping\r\ndata: {}\r\n\r\n';
|
||||
const messages = [...text].flatMap((char) => parser.push(char));
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ event: 'state', data: '{"a":1}' },
|
||||
{ event: 'ping', data: '{}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('줄 끝은 LF · CRLF · CR 모두 받는다', () => {
|
||||
for (const lineBreak of ['\n', '\r\n', '\r']) {
|
||||
const parser = new SseParser();
|
||||
expect(
|
||||
parser.push(`event: x${lineBreak}data: 1${lineBreak}${lineBreak}`),
|
||||
).toEqual([{ event: 'x', data: '1' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('data 여러 줄은 줄바꿈으로 잇고, 이름이 없으면 message · 콜론 뒤 공백은 하나만 뗀다', () => {
|
||||
const parser = new SseParser();
|
||||
|
||||
expect(parser.push('data: 두 칸\ndata:붙임\ndata\n\n')).toEqual([
|
||||
{ event: 'message', data: ' 두 칸\n붙임\n' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('주석 · id · retry 와 data 없는 이벤트는 내보내지 않는다 — 맨 앞 BOM 도 뗀다', () => {
|
||||
const parser = new SseParser();
|
||||
|
||||
expect(
|
||||
parser.push(': 연결 유지\nretry: 1000\nid: 7\n\nevent: empty\n\n'),
|
||||
).toEqual([]);
|
||||
expect(parser.push('data: 다음\n\n')).toEqual([
|
||||
{ event: 'message', data: '다음' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('끝나지 않은 이벤트는 내보내지 않고 기다린다', () => {
|
||||
const parser = new SseParser();
|
||||
|
||||
expect(parser.push('event: state\ndata: {"a"')).toEqual([]);
|
||||
expect(parser.push(':1}\n')).toEqual([]);
|
||||
expect(parser.push('\n')).toEqual([{ event: 'state', data: '{"a":1}' }]);
|
||||
});
|
||||
|
||||
it('한 줄이나 한 이벤트의 data 가 한도를 넘으면 SseFormatError — 줄바꿈 없이 끝없이 보내도 쌓아 두지 않는다', () => {
|
||||
const longLine = new SseParser(10);
|
||||
expect(() => longLine.push('data: 01234567890')).toThrow(SseFormatError);
|
||||
|
||||
const longEvent = new SseParser(20);
|
||||
expect(longEvent.push('data: 0123456789\n')).toEqual([]);
|
||||
expect(() => longEvent.push('data: 0123456789\n')).toThrow(SseFormatError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Server-Sent Events(`text/event-stream` — WHATWG HTML "Server-sent events") 형식.
|
||||
*
|
||||
* - 쓰기(`formatSseEvent`): 실시간 알림(GET /api/mail-events)이 브라우저에 보내는 이벤트 — 순수 함수
|
||||
* - 읽기(`SseParser`): 메일 엔진의 JMAP EventSource(RFC 8620 7.3) — 받은 글자만 다루는 해석기(입출력 없음)
|
||||
*/
|
||||
|
||||
/** 이벤트 이름 — 줄바꿈이 들어가면 다른 필드가 끼어들 수 있어 영문 · 숫자 · `-` · `_` 만 받는다 */
|
||||
const EVENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/** SSE 의 줄 끝 — CRLF · LF · CR 셋 다 (WHATWG 규칙) */
|
||||
const LINE_BREAK = /\r\n|\r|\n/;
|
||||
|
||||
const LF = 0x0a;
|
||||
const CR = 0x0d;
|
||||
|
||||
/** 스트림 맨 앞에 올 수 있는 BOM — 해석하기 전에 뗀다 */
|
||||
const BYTE_ORDER_MARK = '';
|
||||
|
||||
/**
|
||||
* 이벤트 하나를 만든다 — `event: <이름>` · `data: <JSON>` · 빈 줄.
|
||||
* JSON 은 문자열 안의 줄바꿈을 `\n` 으로 적으므로 보통 한 줄이다 — 그래도 줄마다 `data:` 를 붙인다(형식 규칙).
|
||||
*/
|
||||
export function formatSseEvent(name: string, data: object): string {
|
||||
if (!EVENT_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`SSE 이벤트 이름으로 쓸 수 없는 값입니다: ${name}`);
|
||||
}
|
||||
const lines = JSON.stringify(data).split(LINE_BREAK);
|
||||
return `event: ${name}\n${lines.map((line) => `data: ${line}`).join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** 상대가 SSE 형식을 지키지 않았다 (한 줄 · 한 이벤트가 너무 김) — 읽는 쪽이 연결을 닫는다 */
|
||||
export class SseFormatError extends Error {
|
||||
override readonly name = 'SseFormatError';
|
||||
}
|
||||
|
||||
/** 받은 이벤트 하나 */
|
||||
export interface SseMessage {
|
||||
/** `event` 필드 — 없으면 `message` */
|
||||
readonly event: string;
|
||||
/** `data` 줄들을 줄바꿈으로 이은 것 */
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 줄 · 한 이벤트의 data 최대 길이 (JS 문자열 길이) — 상대가 줄바꿈 없이 끝없이 보내도 메모리를 붙잡지 않게.
|
||||
* 메일 엔진의 StateChange 는 계정 하나에 수백 자다.
|
||||
*/
|
||||
export const SSE_MAX_FIELD_LENGTH = 1_048_576;
|
||||
|
||||
/** 이벤트 이름이 없을 때의 이름 (WHATWG 규칙) */
|
||||
const DEFAULT_EVENT_NAME = 'message';
|
||||
|
||||
/**
|
||||
* `text/event-stream` 해석기 — 디코딩한 글자를 받은 대로 넣으면 끝난 이벤트를 돌려준다.
|
||||
* 줄이 조각 사이에서 끊겨도(CRLF 의 CR 과 LF 가 다른 조각이어도) 이어서 읽는다.
|
||||
* 주석(`:` 로 시작) · `id` · `retry` 는 쓰지 않는다. data 가 없는 이벤트는 내보내지 않는다(WHATWG 규칙).
|
||||
* 한 줄이나 한 이벤트의 data 가 `maxLength` 를 넘으면 SseFormatError — 읽는 쪽이 연결을 닫는다.
|
||||
*/
|
||||
export class SseParser {
|
||||
/** 아직 줄 끝을 만나지 못한 글자 */
|
||||
private pending = '';
|
||||
/** 지난 조각이 CR 로 끝났다 — 다음 조각 맨 앞의 LF 는 같은 줄 끝이다 */
|
||||
private skipLeadingLineFeed = false;
|
||||
private started = false;
|
||||
private eventName = '';
|
||||
private dataLines: string[] = [];
|
||||
private dataLength = 0;
|
||||
|
||||
constructor(private readonly maxLength = SSE_MAX_FIELD_LENGTH) {}
|
||||
|
||||
push(chunk: string): SseMessage[] {
|
||||
let text = chunk;
|
||||
if (!this.started && text !== '') {
|
||||
this.started = true;
|
||||
if (text.startsWith(BYTE_ORDER_MARK)) text = text.slice(1);
|
||||
}
|
||||
if (this.skipLeadingLineFeed && text !== '') {
|
||||
this.skipLeadingLineFeed = false;
|
||||
if (text.charCodeAt(0) === LF) text = text.slice(1);
|
||||
}
|
||||
|
||||
const messages: SseMessage[] = [];
|
||||
let start = 0;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code !== LF && code !== CR) continue;
|
||||
|
||||
this.readLine(this.pending + text.slice(start, index), messages);
|
||||
this.pending = '';
|
||||
if (code === CR) {
|
||||
if (index + 1 < text.length) {
|
||||
if (text.charCodeAt(index + 1) === LF) index += 1;
|
||||
} else {
|
||||
this.skipLeadingLineFeed = true;
|
||||
}
|
||||
}
|
||||
start = index + 1;
|
||||
}
|
||||
|
||||
this.pending += text.slice(start);
|
||||
if (this.pending.length > this.maxLength) {
|
||||
throw new SseFormatError('SSE 한 줄이 너무 깁니다');
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private readLine(line: string, messages: SseMessage[]): void {
|
||||
if (line.length > this.maxLength) {
|
||||
throw new SseFormatError('SSE 한 줄이 너무 깁니다');
|
||||
}
|
||||
if (line === '') {
|
||||
this.dispatch(messages);
|
||||
return;
|
||||
}
|
||||
// 주석 — 연결 유지용으로 보내는 서버가 있다
|
||||
if (line.startsWith(':')) return;
|
||||
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
|
||||
if (field === 'event') {
|
||||
this.eventName = value;
|
||||
} else if (field === 'data') {
|
||||
this.dataLength += value.length + 1;
|
||||
if (this.dataLength > this.maxLength) {
|
||||
throw new SseFormatError('SSE 이벤트의 data 가 너무 깁니다');
|
||||
}
|
||||
this.dataLines.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(messages: SseMessage[]): void {
|
||||
if (this.dataLines.length > 0) {
|
||||
messages.push({
|
||||
event: this.eventName || DEFAULT_EVENT_NAME,
|
||||
data: this.dataLines.join('\n'),
|
||||
});
|
||||
}
|
||||
this.eventName = '';
|
||||
this.dataLines = [];
|
||||
this.dataLength = 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { createHash } from 'crypto';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import type { Server } from 'http';
|
||||
import { get as httpGet, type IncomingMessage, type Server } from 'http';
|
||||
import { basename, join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import request from 'supertest';
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
MailboxCreate,
|
||||
MailboxDeletion,
|
||||
MailVacation,
|
||||
MailWatch,
|
||||
MailWatchListener,
|
||||
MessageDeletionResult,
|
||||
MessageListQuery,
|
||||
MessageSource,
|
||||
@@ -141,6 +143,9 @@ const TAKEN_MAILBOX_NAME = '이미 있는 이름';
|
||||
/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */
|
||||
const SLOW_MAILBOX = 'slowbox';
|
||||
|
||||
/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */
|
||||
const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test';
|
||||
|
||||
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
|
||||
const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
isEnabled: false,
|
||||
@@ -196,6 +201,53 @@ function listenOnAnyPort(server: Server): Promise<void> {
|
||||
return new Promise((resolve) => server.listen(0, () => resolve()));
|
||||
}
|
||||
|
||||
/** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */
|
||||
interface EventStreamClient {
|
||||
readonly response: IncomingMessage;
|
||||
/** 지금까지 받은 글자 */
|
||||
received(): string;
|
||||
/** 클라이언트가 끊는다 */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다.
|
||||
* 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다
|
||||
*/
|
||||
function openEventStream(
|
||||
server: Server,
|
||||
cookie: string,
|
||||
): Promise<EventStreamClient> {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpGet(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/mail-events',
|
||||
headers: { cookie, accept: 'text/event-stream' },
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => {
|
||||
text += chunk;
|
||||
});
|
||||
// 끊은 뒤의 오류(ECONNRESET 등)는 테스트와 상관없다
|
||||
response.on('error', () => undefined);
|
||||
resolve({
|
||||
response,
|
||||
received: () => text,
|
||||
close: () => request.destroy(),
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) =>
|
||||
@@ -269,6 +321,13 @@ describe('응답 계약 (e2e)', () => {
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
|
||||
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
|
||||
const liveWatches: {
|
||||
user: string;
|
||||
listener: MailWatchListener;
|
||||
closed: boolean;
|
||||
}[] = [];
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
jwt.sign(payload, {
|
||||
@@ -385,6 +444,28 @@ describe('응답 계약 (e2e)', () => {
|
||||
Promise.resolve(correspondents.get(user) ?? []),
|
||||
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
|
||||
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
|
||||
// 5단계 — 엔진 연결(다시 잇기 · 새 메일 계산)은 jmap-mail-watch.spec.ts · jmap-mail.provider.spec.ts 가 본다.
|
||||
// 여기서는 처음 상태만 주고, 소식은 테스트가 listener 로 흘려 넣는다
|
||||
watchMail: (
|
||||
user: string,
|
||||
listener: MailWatchListener,
|
||||
): Promise<MailWatch> => {
|
||||
if (user === EVENTS_ENGINE_DOWN) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_001', {
|
||||
context: { reason: 'e2e-engine-down' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const watch = { user, listener, closed: false };
|
||||
liveWatches.push(watch);
|
||||
return Promise.resolve({
|
||||
initial: { state: MAIL_STATE, inboxUnread: 2 },
|
||||
close: () => {
|
||||
watch.closed = true;
|
||||
},
|
||||
});
|
||||
},
|
||||
listMessages: (user: string, query: MessageListQuery) => {
|
||||
listQueries.push({ user, query });
|
||||
return Promise.resolve({
|
||||
@@ -935,6 +1016,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
],
|
||||
['get', '/api/recipient-suggestions'],
|
||||
['get', '/api/mail-state'],
|
||||
['get', '/api/mail-events'],
|
||||
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
@@ -2443,6 +2525,127 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 5단계 — 실시간 알림 (계약 2절 "실시간 알림").
|
||||
* 엔진 연결(다시 잇기 · 무소식 감시 · 새 메일 계산)과 연결 관리(나눠 쓰기 · 30초 · 토큰 만료 · 종료)는 단위 테스트가 본다 —
|
||||
* 여기서는 실제 HTTP 의 머리글 · 이벤트 형식 · 연결 전 실패의 표준 JSON 을 본다.
|
||||
* 응답이 끝나지 않으므로 서버를 미리 열어 http.get 으로 읽는다 (로그인 동시 시도 테스트와 같은 이유로 테스트가 끝나면 닫는다)
|
||||
*/
|
||||
describe('5단계 — 실시간 알림 (GET /api/mail-events)', () => {
|
||||
/** 받은 글자에 빈 줄로 끝난 이벤트가 n 개 쌓일 때까지 기다린다 */
|
||||
const eventsIn = (text: string) =>
|
||||
text.split('\n\n').filter((block) => block !== '');
|
||||
const waitForEvents = (stream: EventStreamClient, count: number) =>
|
||||
waitUntil(
|
||||
() =>
|
||||
stream.received().endsWith('\n\n') &&
|
||||
eventsIn(stream.received()).length >= count,
|
||||
GATE_DEADLINE_MS,
|
||||
);
|
||||
|
||||
/** 서버를 미리 열고, 끝나면 연 연결을 모두 끊고 닫는다 */
|
||||
async function withListeningServer(
|
||||
run: (httpServer: Server, opened: EventStreamClient[]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
const opened: EventStreamClient[] = [];
|
||||
try {
|
||||
await run(httpServer, opened);
|
||||
} finally {
|
||||
for (const stream of opened) stream.close();
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
}
|
||||
|
||||
it(
|
||||
'200 text/event-stream — 머리글(charset · no-cache, no-transform · X-Accel-Buffering: no)과 처음 state, 그 뒤 엔진 소식을 이어서 보낸다',
|
||||
async () => {
|
||||
const ME = 'events.me@e2e.arasystem.test';
|
||||
await withListeningServer(async (httpServer, opened) => {
|
||||
const stream = await openEventStream(httpServer, cookieOf(ME));
|
||||
opened.push(stream);
|
||||
await waitForEvents(stream, 1);
|
||||
|
||||
const { headers, statusCode } = stream.response;
|
||||
expect(statusCode).toBe(200);
|
||||
expect(headers['content-type']).toBe(
|
||||
'text/event-stream; charset=utf-8',
|
||||
);
|
||||
expect(headers['cache-control']).toBe('no-cache, no-transform');
|
||||
expect(headers['x-accel-buffering']).toBe('no');
|
||||
expect(typeof headers['x-request-id']).toBe('string');
|
||||
// 표준 래퍼 없이 — 상태 값은 GET /api/mail-state 와 같다
|
||||
expect(stream.received()).toBe(
|
||||
`event: state\ndata: ${JSON.stringify({ state: MAIL_STATE, inboxUnread: 2 })}\n\n`,
|
||||
);
|
||||
|
||||
const watch = liveWatches.find(({ user }) => user === ME);
|
||||
expect(watch).toBeDefined();
|
||||
const newMail = {
|
||||
count: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
from: {
|
||||
name: '박서준',
|
||||
email: 'seojun.park@e2e.arasystem.test',
|
||||
},
|
||||
subject: '줄\n바꿈이 있는 제목',
|
||||
receivedAt: '2026-10-28T05:42:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
watch?.listener.state({ state: 'c2Iz.c2Fr', inboxUnread: 3 });
|
||||
watch?.listener.newMail(newMail);
|
||||
await waitForEvents(stream, 3);
|
||||
|
||||
expect(eventsIn(stream.received()).slice(1)).toEqual([
|
||||
`event: state\ndata: ${JSON.stringify({ state: 'c2Iz.c2Fr', inboxUnread: 3 })}`,
|
||||
`event: new-mail\ndata: ${JSON.stringify(newMail)}`,
|
||||
]);
|
||||
});
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it('처음 연결 때 엔진에 연결할 수 없으면 스트림을 열지 않고 503 MAIL_001 (표준 실패 JSON)', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-events')
|
||||
.set('Cookie', cookieOf(EVENTS_ENGINE_DOWN))
|
||||
.expect(503);
|
||||
|
||||
expect(res.headers['content-type']).toMatch(/^application\/json/);
|
||||
expect(body(res).success).toBe(false);
|
||||
expect(body(res).error?.code).toBe('MAIL_001');
|
||||
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
||||
});
|
||||
|
||||
it(
|
||||
'한 직원의 연결이 5개를 넘으면 429 REQ_001 (표준 실패 JSON) — 엔진 연결은 하나를 나눠 쓴다',
|
||||
async () => {
|
||||
const ME = 'events.limit@e2e.arasystem.test';
|
||||
await withListeningServer(async (httpServer, opened) => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const stream = await openEventStream(httpServer, cookieOf(ME));
|
||||
opened.push(stream);
|
||||
await waitForEvents(stream, 1);
|
||||
expect(stream.response.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get('/api/mail-events')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(429);
|
||||
expect(body(res).error?.code).toBe('REQ_001');
|
||||
expect(liveWatches.filter(({ user }) => user === ME)).toHaveLength(1);
|
||||
});
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
|
||||
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 메일 엔진의 변화 소식 스트림(JMAP EventSource) 대역 — 단위 테스트 공용.
|
||||
* fetch 본문처럼 바이트를 흘려보내고, 읽는 쪽이 닫으면(abort) 읽던 자리에 오류를 낸다.
|
||||
* 테스트가 `send` · `end` 로 엔진 쪽을 흉내 낸다. 타이머를 쓰지 않는다 (promise 로만 돈다 — 가짜 타이머와 함께 써도 된다).
|
||||
*/
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export class FakeEventStream {
|
||||
/** 읽는 쪽이 닫았다 */
|
||||
aborted = false;
|
||||
private readonly queue: (Uint8Array | Error | null)[] = [];
|
||||
private wake: (() => void) | null = null;
|
||||
|
||||
constructor(signal: AbortSignal) {
|
||||
const abort = () => {
|
||||
this.aborted = true;
|
||||
this.push(new Error('aborted'));
|
||||
};
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
|
||||
/** 엔진이 글자를 보낸다 */
|
||||
send(text: string): void {
|
||||
this.push(encoder.encode(text));
|
||||
}
|
||||
|
||||
/** 엔진이 스트림을 닫는다 */
|
||||
end(): void {
|
||||
this.push(null);
|
||||
}
|
||||
|
||||
async *body(): AsyncGenerator<Uint8Array> {
|
||||
for (;;) {
|
||||
while (this.queue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wake = resolve;
|
||||
});
|
||||
}
|
||||
const item = this.queue.shift();
|
||||
if (item === undefined || item === null) return;
|
||||
if (item instanceof Error) throw item;
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
private push(item: Uint8Array | Error | null): void {
|
||||
this.queue.push(item);
|
||||
this.wake?.();
|
||||
this.wake = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 이 계정의 메일 · 메일함 변화 신호 — Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 확인) */
|
||||
export function stateChangeEvent(accountId: string): string {
|
||||
return `event: state\ndata: ${JSON.stringify({
|
||||
'@type': 'StateChange',
|
||||
changed: { [accountId]: { Email: 'x', Mailbox: 'y' } },
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
/** 엔진의 연결 유지 신호 */
|
||||
export const PING_EVENT = 'event: ping\ndata: {"interval": 30}\n\n';
|
||||
|
||||
/** 미뤄 둔 비동기 처리(스트림 읽기 · 엔진 묻기의 결과)를 마저 돌린다 — 타이머는 움직이지 않는다 (setImmediate 는 가짜로 두지 않는다) */
|
||||
export async function settleAsync(): Promise<void> {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user