generated from ttipo/project-base
feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)
첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다. - 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다. 리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기) - 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다 - 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다 - 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다 - 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다) - 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다 계약: docs/mail/api-contract.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
clearSessionCookies,
|
||||
sessionCookieOptions,
|
||||
setSessionCookies,
|
||||
} from './auth-cookies';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
const settings: AuthSettings = {
|
||||
jwtSecret: 'secret',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 14 * 24 * 3600,
|
||||
secureCookies: false,
|
||||
};
|
||||
|
||||
function fakeResponse() {
|
||||
return {
|
||||
cookie: jest.fn(),
|
||||
clearCookie: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('세션 쿠키', () => {
|
||||
it('액세스 쿠키: HttpOnly · SameSite=Lax · Path=/api · Max-Age=액세스 만료', () => {
|
||||
expect(sessionCookieOptions('access', settings)).toEqual({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: 3600 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('리프레시 쿠키: HttpOnly · SameSite=Strict · Path=/api/auth · Max-Age=리프레시 만료', () => {
|
||||
expect(sessionCookieOptions('refresh', settings)).toEqual({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 14 * 24 * 3600 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('운영에서는 Secure 를 붙인다', () => {
|
||||
const production = { ...settings, secureCookies: true };
|
||||
|
||||
expect(sessionCookieOptions('access', production).secure).toBe(true);
|
||||
expect(sessionCookieOptions('refresh', production).secure).toBe(true);
|
||||
});
|
||||
|
||||
it('두 쿠키를 함께 심고, 지울 때는 같은 Path·속성으로 지운다', () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
setSessionCookies(
|
||||
res as unknown as Response,
|
||||
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
|
||||
settings,
|
||||
);
|
||||
clearSessionCookies(res as unknown as Response, settings);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'a.b.c',
|
||||
expect.objectContaining({ path: '/api' }),
|
||||
);
|
||||
// 리프레시 쿠키 Max-Age 는 세션의 남은 수명을 따른다 (절대 만료가 가까우면 짧아진다)
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'd.e.f',
|
||||
expect.objectContaining({ path: '/api/auth', maxAge: 600 * 1000 }),
|
||||
);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('access_token', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
});
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('refresh_token', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CookieOptions, Response } from 'express';
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
ACCESS_TOKEN_COOKIE_PATH,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE_PATH,
|
||||
} from './auth.constants';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
/**
|
||||
* 세션 쿠키 — 계약 1절. 토큰은 응답 본문에 싣지 않고 HttpOnly 쿠키로만 주고받는다.
|
||||
*
|
||||
* | 쿠키 | SameSite | Path | Max-Age |
|
||||
* | access_token | Lax | /api | JWT_ACCESS_EXPIRES_IN |
|
||||
* | refresh_token | Strict | /api/auth | 세션의 남은 미사용 기간 (JWT_REFRESH_EXPIRES_IN, 절대 만료 30일 이내) |
|
||||
*/
|
||||
|
||||
export interface SessionTokens {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
|
||||
readonly refreshTtlSeconds: number;
|
||||
}
|
||||
|
||||
type CookieKind = 'access' | 'refresh';
|
||||
|
||||
/** 지울 때도 같은 Path·속성을 써야 브라우저가 같은 쿠키로 알아본다 */
|
||||
function baseOptions(kind: CookieKind, settings: AuthSettings): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: settings.secureCookies,
|
||||
sameSite: kind === 'access' ? 'lax' : 'strict',
|
||||
path:
|
||||
kind === 'access' ? ACCESS_TOKEN_COOKIE_PATH : REFRESH_TOKEN_COOKIE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(
|
||||
kind: CookieKind,
|
||||
settings: AuthSettings,
|
||||
ttlSeconds: number = kind === 'access'
|
||||
? settings.accessTtlSeconds
|
||||
: settings.refreshTtlSeconds,
|
||||
): CookieOptions {
|
||||
// express 의 maxAge 는 밀리초다 (헤더에는 초 단위 Max-Age 와 Expires 로 나간다)
|
||||
return { ...baseOptions(kind, settings), maxAge: ttlSeconds * 1000 };
|
||||
}
|
||||
|
||||
export function setSessionCookies(
|
||||
res: Response,
|
||||
tokens: SessionTokens,
|
||||
settings: AuthSettings,
|
||||
): void {
|
||||
res.cookie(
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
tokens.accessToken,
|
||||
sessionCookieOptions('access', settings),
|
||||
);
|
||||
res.cookie(
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
tokens.refreshToken,
|
||||
sessionCookieOptions('refresh', settings, tokens.refreshTtlSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSessionCookies(
|
||||
res: Response,
|
||||
settings: AuthSettings,
|
||||
): void {
|
||||
res.clearCookie(ACCESS_TOKEN_COOKIE, baseOptions('access', settings));
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, baseOptions('refresh', settings));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import type { AuthSessionRepository } from './auth-session.repository';
|
||||
|
||||
describe('AuthSessionCleanup', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('보존 기간(7일)보다 먼저 끝난 세션을 지운다', async () => {
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
const deleteEndedBefore = jest.fn().mockResolvedValue(3);
|
||||
const cleanup = new AuthSessionCleanup({
|
||||
deleteEndedBefore,
|
||||
} as unknown as AuthSessionRepository);
|
||||
const now = new Date('2026-10-26T09:00:00.000Z');
|
||||
|
||||
await expect(cleanup.purgeEndedSessions(now)).resolves.toBe(3);
|
||||
expect(deleteEndedBefore).toHaveBeenCalledWith(
|
||||
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
|
||||
);
|
||||
});
|
||||
|
||||
it('DB 오류가 나도 앱을 멈추지 않고 오류 로그만 남긴다 (다음 주기에 다시 한다)', async () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const cleanup = new AuthSessionCleanup({
|
||||
deleteEndedBefore: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
} as unknown as AuthSessionRepository);
|
||||
|
||||
await expect(cleanup.purgeEndedSessions()).resolves.toBe(0);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[SYS_001] 끝난 세션 정리에 실패했습니다'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
|
||||
/**
|
||||
* 끝난 세션 정리 — 만료·폐기되고 보존 기간(7일)이 지난 세션을 지운다.
|
||||
* 보존 기간 동안은 "왜 로그아웃됐나요" 문의에 폐기 사유(revoked_reason)로 답할 수 있다.
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionCleanup {
|
||||
private readonly logger = new Logger(AuthSessionCleanup.name);
|
||||
|
||||
constructor(private readonly sessions: AuthSessionRepository) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR, { name: 'auth-session-cleanup' })
|
||||
async purgeEndedSessions(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.sessions.deleteEndedBefore(
|
||||
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
|
||||
);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0)
|
||||
this.logger.debug(`끝난 세션 ${removed}건을 지웠습니다.`);
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 끝난 세션 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** SHA-256 16진 문자열 길이 */
|
||||
const TOKEN_HASH_LENGTH = 64;
|
||||
/** 폐기 사유 길이 */
|
||||
const REVOKED_REASON_LENGTH = 32;
|
||||
|
||||
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
|
||||
export type SessionRevokedReason =
|
||||
| 'logout'
|
||||
| 'refresh-token-reused'
|
||||
| 'account-unavailable';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
*
|
||||
* - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다)
|
||||
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다)
|
||||
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
|
||||
*/
|
||||
@Entity({ name: 'auth_sessions' })
|
||||
export class AuthSessionEntity {
|
||||
/** 세션 id — 리프레시 토큰의 `sid` 클레임. 토큰을 서명하기 전에 정해야 하므로 앱이 만든다 */
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 지금 쓸 수 있는 리프레시 토큰의 해시 */
|
||||
@Column({ name: 'token_hash', type: 'char', length: TOKEN_HASH_LENGTH })
|
||||
tokenHash!: string;
|
||||
|
||||
/** 바로 이전 토큰의 해시 — 동시 갱신 유예 판단용 (그보다 오래된 토큰이 오면 재사용) */
|
||||
@Column({
|
||||
name: 'previous_token_hash',
|
||||
type: 'char',
|
||||
length: TOKEN_HASH_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
previousTokenHash!: string | null;
|
||||
|
||||
/** 마지막 회전 시각 */
|
||||
@Column({ name: 'rotated_at', type: 'timestamptz', nullable: true })
|
||||
rotatedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 미사용 만료 — 회전할 때마다 늘어나지만 절대 만료를 넘지 않는다 */
|
||||
@Index('IDX_auth_sessions_expires_at')
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
/** 절대 만료 — 로그인 후 30일 */
|
||||
@Column({ name: 'absolute_expires_at', type: 'timestamptz' })
|
||||
absoluteExpiresAt!: Date;
|
||||
|
||||
/** 폐기 시각 — 정리 작업이 이 열로 찾으므로 폐기된 행만 담는 부분 인덱스를 둔다 (대부분의 행은 null) */
|
||||
@Index('IDX_auth_sessions_revoked_at', { where: '"revoked_at" IS NOT NULL' })
|
||||
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||
revokedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'revoked_reason',
|
||||
type: 'varchar',
|
||||
length: REVOKED_REASON_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
revokedReason!: SessionRevokedReason | null;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
classifyRefresh,
|
||||
hashRefreshToken,
|
||||
nextIdleExpiry,
|
||||
type SessionSnapshot,
|
||||
} from './auth-session.policy';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-26T09:00:00.000Z');
|
||||
const GRACE_MS = 30_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const CURRENT = hashRefreshToken('current.token');
|
||||
const PREVIOUS = hashRefreshToken('previous.token');
|
||||
const OLDER = hashRefreshToken('older.token');
|
||||
|
||||
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
return {
|
||||
userEmail: ARA,
|
||||
tokenHash: CURRENT,
|
||||
previousTokenHash: PREVIOUS,
|
||||
rotatedAt: new Date(NOW.getTime() - 5_000),
|
||||
expiresAt: new Date(NOW.getTime() + DAY_MS),
|
||||
absoluteExpiresAt: new Date(NOW.getTime() + 10 * DAY_MS),
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
|
||||
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
|
||||
|
||||
describe('classifyRefresh', () => {
|
||||
it('지금 토큰이면 current', () => {
|
||||
expect(classify(session(), CURRENT)).toBe('current');
|
||||
});
|
||||
|
||||
it('바로 이전 토큰이 유예 시간 안에 오면 grace (동시 갱신)', () => {
|
||||
expect(classify(session(), PREVIOUS)).toBe('grace');
|
||||
});
|
||||
|
||||
it('바로 이전 토큰이라도 유예 시간이 지나면 재사용(reused)', () => {
|
||||
const rotatedLongAgo = session({
|
||||
rotatedAt: new Date(NOW.getTime() - GRACE_MS),
|
||||
});
|
||||
expect(classify(rotatedLongAgo, PREVIOUS)).toBe('reused');
|
||||
});
|
||||
|
||||
it('두 번 이상 지난 토큰은 시간과 무관하게 재사용(reused)', () => {
|
||||
expect(classify(session(), OLDER)).toBe('reused');
|
||||
});
|
||||
|
||||
it('회전한 적 없는 세션에 다른 토큰이 오면 재사용(reused)', () => {
|
||||
expect(
|
||||
classify(session({ previousTokenHash: null, rotatedAt: null }), OLDER),
|
||||
).toBe('reused');
|
||||
});
|
||||
|
||||
it('끊긴 세션은 어떤 토큰이든 revoked', () => {
|
||||
const revoked = session({ revokedAt: new Date(NOW.getTime() - 1) });
|
||||
expect(classify(revoked, CURRENT)).toBe('revoked');
|
||||
expect(classify(revoked, OLDER)).toBe('revoked');
|
||||
});
|
||||
|
||||
it('미사용 만료·절대 만료가 지나면 expired', () => {
|
||||
expect(classify(session({ expiresAt: NOW }), CURRENT)).toBe('expired');
|
||||
expect(classify(session({ absoluteExpiresAt: NOW }), CURRENT)).toBe(
|
||||
'expired',
|
||||
);
|
||||
});
|
||||
|
||||
it('세션이 없거나 주인이 다르면 missing', () => {
|
||||
expect(classify(null, CURRENT)).toBe('missing');
|
||||
expect(
|
||||
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
|
||||
).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashRefreshToken', () => {
|
||||
it('SHA-256 16진 64자 — 원문을 담지 않는다', () => {
|
||||
const hash = hashRefreshToken('header.payload.signature');
|
||||
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hash).not.toContain('payload');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextIdleExpiry', () => {
|
||||
it('지금 + 미사용 기간, 단 절대 만료를 넘지 않는다', () => {
|
||||
const absolute = new Date(NOW.getTime() + 20 * DAY_MS);
|
||||
|
||||
expect(nextIdleExpiry(NOW, 14 * 24 * 3600, absolute)).toEqual(
|
||||
new Date(NOW.getTime() + 14 * DAY_MS),
|
||||
);
|
||||
expect(nextIdleExpiry(NOW, 30 * 24 * 3600, absolute)).toEqual(absolute);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
|
||||
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
|
||||
export interface SessionSnapshot {
|
||||
readonly userEmail: string;
|
||||
readonly tokenHash: string;
|
||||
readonly previousTokenHash: string | null;
|
||||
readonly rotatedAt: Date | null;
|
||||
readonly expiresAt: Date;
|
||||
readonly absoluteExpiresAt: Date;
|
||||
readonly revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 요청이 세션의 어느 상태에 해당하는가 (계약 1절 "세션 규칙").
|
||||
*
|
||||
* - current: 지금 토큰 → 회전한다
|
||||
* - grace: 바로 이전 토큰이 회전 직후 유예 시간 안에 왔다 → 동시 갱신(탭 여러 개). 새 토큰 없이 성공만
|
||||
* - reused: 이미 쓴 토큰 → 탈취로 보고 세션 전체를 끊는다
|
||||
* - revoked / expired / missing: 끝난 세션 → AUTH_001
|
||||
*/
|
||||
export type RefreshState =
|
||||
| 'current'
|
||||
| 'grace'
|
||||
| 'reused'
|
||||
| 'revoked'
|
||||
| 'expired'
|
||||
| 'missing';
|
||||
|
||||
/** 리프레시 토큰을 저장할 형태 — SHA-256 16진 (토큰은 무작위 jti 를 담아 엔트로피가 충분하다) */
|
||||
export function hashRefreshToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function sameHash(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a, 'hex');
|
||||
const right = Buffer.from(b, 'hex');
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function classifyRefresh(
|
||||
session: SessionSnapshot | null,
|
||||
claims: { readonly sub: string },
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
graceMs: number,
|
||||
): RefreshState {
|
||||
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
|
||||
if (!session || session.userEmail !== claims.sub) return 'missing';
|
||||
if (session.revokedAt !== null) return 'revoked';
|
||||
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
|
||||
return 'expired';
|
||||
}
|
||||
if (sameHash(session.tokenHash, presentedHash)) return 'current';
|
||||
|
||||
const withinGrace =
|
||||
session.previousTokenHash !== null &&
|
||||
session.rotatedAt !== null &&
|
||||
sameHash(session.previousTokenHash, presentedHash) &&
|
||||
now.getTime() - session.rotatedAt.getTime() < graceMs;
|
||||
return withinGrace ? 'grace' : 'reused';
|
||||
}
|
||||
|
||||
/** 다음 미사용 만료 — 지금 + 미사용 기간, 단 절대 만료를 넘지 않는다 */
|
||||
export function nextIdleExpiry(
|
||||
now: Date,
|
||||
idleTtlSeconds: number,
|
||||
absoluteExpiresAt: Date,
|
||||
): Date {
|
||||
const idle = now.getTime() + idleTtlSeconds * 1000;
|
||||
return new Date(Math.min(idle, absoluteExpiresAt.getTime()));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, MoreThan, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthSessionEntity,
|
||||
type SessionRevokedReason,
|
||||
} from './auth-session.entity';
|
||||
|
||||
export interface RotateSessionCommand {
|
||||
readonly id: string;
|
||||
/** 요청에 실려 온 토큰의 해시 — 지금 토큰이 이것일 때만 바꾼다 */
|
||||
readonly presentedHash: string;
|
||||
readonly nextHash: string;
|
||||
readonly nextExpiresAt: Date;
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionRepository {
|
||||
constructor(
|
||||
@InjectRepository(AuthSessionEntity)
|
||||
private readonly sessions: Repository<AuthSessionEntity>,
|
||||
) {}
|
||||
|
||||
async create(session: AuthSessionEntity): Promise<void> {
|
||||
await this.sessions.insert(session);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<AuthSessionEntity | null> {
|
||||
return this.sessions.findOneBy({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 1회용 회전 — **조건부 UPDATE 한 번**으로 한다.
|
||||
* 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중
|
||||
* 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다).
|
||||
* @returns 바꿨으면 true
|
||||
*/
|
||||
async rotate(command: RotateSessionCommand): Promise<boolean> {
|
||||
const result = await this.sessions.update(
|
||||
{
|
||||
id: command.id,
|
||||
tokenHash: command.presentedHash,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(command.now),
|
||||
absoluteExpiresAt: MoreThan(command.now),
|
||||
},
|
||||
{
|
||||
tokenHash: command.nextHash,
|
||||
previousTokenHash: command.presentedHash,
|
||||
rotatedAt: command.now,
|
||||
expiresAt: command.nextExpiresAt,
|
||||
},
|
||||
);
|
||||
return result.affected === 1;
|
||||
}
|
||||
|
||||
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */
|
||||
async revoke(
|
||||
id: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
await this.sessions.update(
|
||||
{ id, revokedAt: IsNull() },
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수.
|
||||
* 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다.
|
||||
* 두 조건 모두 인덱스를 탄다 — expires_at 인덱스, revoked_at 부분 인덱스(폐기된 행만).
|
||||
*/
|
||||
async deleteEndedBefore(before: Date): Promise<number> {
|
||||
const result = await this.sessions
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(AuthSessionEntity)
|
||||
.where('expires_at < :before OR revoked_at < :before', { before })
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* JWT 페이로드.
|
||||
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
|
||||
*/
|
||||
export type AuthTokenType = 'access' | 'refresh';
|
||||
|
||||
export interface AuthTokenPayload {
|
||||
/** 직원 메일 주소 */
|
||||
readonly sub: string;
|
||||
readonly typ: AuthTokenType;
|
||||
/** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */
|
||||
readonly name?: string;
|
||||
/** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */
|
||||
readonly sid?: string;
|
||||
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
|
||||
readonly jti?: string;
|
||||
readonly iat?: number;
|
||||
readonly exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 인증 상수 — 계약(docs/mail/api-contract.md 1절)의 쿠키 규칙과 한도를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/** 액세스 JWT 쿠키 — 모든 API 경로로 간다 */
|
||||
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||
export const ACCESS_TOKEN_COOKIE_PATH = '/api';
|
||||
|
||||
/** 리프레시 JWT 쿠키 — 인증 경로로만 간다 (다른 API 요청에 실려 다니지 않게) */
|
||||
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/auth';
|
||||
|
||||
const MINUTE_SECONDS = 60;
|
||||
const HOUR_SECONDS = 60 * MINUTE_SECONDS;
|
||||
const DAY_SECONDS = 24 * HOUR_SECONDS;
|
||||
|
||||
/** env 가 없을 때의 만료 기본값 (JWT_ACCESS_EXPIRES_IN · JWT_REFRESH_EXPIRES_IN) */
|
||||
export const DEFAULT_ACCESS_TTL_SECONDS = HOUR_SECONDS;
|
||||
/** 리프레시 = 세션의 **미사용** 만료 — 이 기간 동안 갱신하지 않으면 세션이 끝난다 (계약 1절) */
|
||||
export const DEFAULT_REFRESH_TTL_SECONDS = 14 * DAY_SECONDS;
|
||||
|
||||
/**
|
||||
* 액세스 토큰 만료 허용 범위 (env 검증) — 액세스 토큰은 서버 조회 없이 검증되므로
|
||||
* 세션을 끊어도 만료까지 유효하다. 그래서 짧게 둔다 (.claude/rules/security.md).
|
||||
*/
|
||||
export const ACCESS_TTL_MIN_SECONDS = 5 * MINUTE_SECONDS;
|
||||
export const ACCESS_TTL_MAX_SECONDS = HOUR_SECONDS;
|
||||
|
||||
/** 세션 절대 만료 — 로그인 후 이 기간이 지나면 갱신해도 끝난다 (계약 1절) */
|
||||
export const SESSION_ABSOLUTE_TTL_SECONDS = 30 * DAY_SECONDS;
|
||||
/** 리프레시(미사용 만료)는 절대 만료를 넘을 수 없다 */
|
||||
export const REFRESH_TTL_MAX_SECONDS = SESSION_ABSOLUTE_TTL_SECONDS;
|
||||
|
||||
/**
|
||||
* 직전 리프레시 토큰의 유예 시간 (밀리초).
|
||||
* 탭 두 개가 같은 리프레시 쿠키로 동시에 갱신하면 한쪽은 이미 회전된 토큰을 내게 된다.
|
||||
* 회전 직후 이 시간 안에 **바로 이전** 토큰이 오면 탈취로 보지 않고, 새 토큰 없이 성공만 돌려준다
|
||||
* (이긴 쪽 응답이 이미 새 쿠키를 구웠다). 유예 중에도 토큰을 새로 주지 않으므로 훔친 토큰으로 얻는 것은 없다.
|
||||
* 이 시간이 지난 뒤 오거나 두 번 이상 지난 토큰이 오면 재사용으로 보고 세션 전체를 끊는다.
|
||||
*/
|
||||
export const REFRESH_REUSE_GRACE_MS = 30_000;
|
||||
|
||||
/** 끝난 세션(만료·폐기)을 지우기 전까지 남겨 두는 기간 — "왜 로그아웃됐나요" 문의 확인용 */
|
||||
export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
|
||||
|
||||
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
|
||||
export const JWT_ALGORITHM = 'HS256';
|
||||
|
||||
/**
|
||||
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
|
||||
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
|
||||
* 메일 엔진은 백엔드(신뢰 대역)를 자동 차단에서 빼므로 무차별 대입 방어는 백엔드 몫이다 (mail/README.md).
|
||||
* 두 한도 모두 없는 주소에도 똑같이 적용해 계정 존재 여부를 드러내지 않는다.
|
||||
*/
|
||||
/** 같은 IP 에서 1분에 실패 20회 — 여러 계정을 돌아가며 찔러 보는 공격 */
|
||||
export const LOGIN_IP_FAILURE_LIMIT = 20;
|
||||
export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000;
|
||||
|
||||
/** 같은 주소로 15분에 실패 10회 — IP 를 바꿔 가며 한 계정을 노리는 공격 */
|
||||
export const LOGIN_ADDRESS_FAILURE_LIMIT = 10;
|
||||
export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다.
|
||||
* 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다.
|
||||
* 사무실 NAT(여러 직원이 한 IP)를 고려해 정상 사용으로는 닿지 않는 값으로 둔다.
|
||||
*/
|
||||
export const LOGIN_REQUEST_LIMIT = 60;
|
||||
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
|
||||
|
||||
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
|
||||
export const MAX_PASSWORD_LENGTH = 256;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { AuthController } from './auth.controller';
|
||||
import type { AuthService } from './auth.service';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
const settings: AuthSettings = {
|
||||
jwtSecret: 'secret',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
};
|
||||
|
||||
const session = {
|
||||
user: { email: 'ara.kim@arasystem.test', name: '김아라' },
|
||||
tokens: {
|
||||
accessToken: 'new.access',
|
||||
refreshToken: 'new.refresh',
|
||||
refreshTtlSeconds: 1209600,
|
||||
},
|
||||
};
|
||||
|
||||
function fakeResponse() {
|
||||
return { cookie: jest.fn(), clearCookie: jest.fn() };
|
||||
}
|
||||
|
||||
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
|
||||
function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
AuthController.prototype,
|
||||
name,
|
||||
);
|
||||
return descriptor?.value as object;
|
||||
}
|
||||
|
||||
describe('AuthController', () => {
|
||||
let auth: { login: jest.Mock; refresh: jest.Mock; logout: jest.Mock };
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
auth = {
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
controller = new AuthController(auth as unknown as AuthService, settings);
|
||||
});
|
||||
|
||||
it('/auth/refresh 에는 가드가 없다 — 액세스 쿠키 없이 리프레시 쿠키만으로 동작한다', () => {
|
||||
const refreshGuards: unknown = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
handlerOf('refresh'),
|
||||
);
|
||||
const meGuards: unknown = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
handlerOf('me'),
|
||||
);
|
||||
|
||||
expect(refreshGuards).toBeUndefined();
|
||||
expect(meGuards).toEqual([JwtAuthGuard]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, AuthController),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인에는 넉넉한 요청 수 상한(IP 당 1분 60회)이 따로 걸린다 — 무차별 대입은 실패 횟수로 막는다', () => {
|
||||
const handler = handlerOf('login');
|
||||
|
||||
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', handler)).toBe(60);
|
||||
expect(Reflect.getMetadata('THROTTLER:TTLdefault', handler)).toBe(60_000);
|
||||
// 다른 인증 엔드포인트는 전역 기본값을 쓴다
|
||||
expect(
|
||||
Reflect.getMetadata('THROTTLER:LIMITdefault', handlerOf('refresh')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => {
|
||||
auth.login.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.login(
|
||||
{ email: 'ara.kim@arasystem.test', password: 'pw' },
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.login).toHaveBeenCalledWith(
|
||||
'ara.kim@arasystem.test',
|
||||
'pw',
|
||||
'203.0.113.10',
|
||||
);
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
|
||||
auth.refresh.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
const req = { cookies: { refresh_token: 'old.refresh' } };
|
||||
|
||||
const user = await controller.refresh(
|
||||
req as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.refresh).toHaveBeenCalledWith('old.refresh');
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'new.access',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'new.refresh',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => {
|
||||
auth.refresh.mockResolvedValue({ user: session.user, tokens: null });
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('리프레시가 AUTH_001 이면 두 쿠키를 지운다', async () => {
|
||||
auth.refresh.mockRejectedValue(new BusinessException('AUTH_001'));
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: {} } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('메일 엔진 장애(MAIL_001)면 세션을 남긴다 (쿠키를 지우지 않는다)', async () => {
|
||||
auth.refresh.mockRejectedValue(new BusinessException('MAIL_001'));
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.logout(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auth.logout).toHaveBeenCalledWith('old.refresh');
|
||||
expect(res.clearCookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Ip,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
|
||||
import {
|
||||
LOGIN_REQUEST_LIMIT,
|
||||
LOGIN_REQUEST_WINDOW_MS,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthUserDto } from './dto/auth-user.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
|
||||
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
|
||||
) {}
|
||||
|
||||
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
|
||||
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
|
||||
@Post('login')
|
||||
@Throttle({
|
||||
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 성공' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_004',
|
||||
'계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '형식 오류')
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Ip() ip: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
const result = await this.auth.login(dto.email, dto.password, ip);
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' })
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 상태' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다',
|
||||
)
|
||||
me(@CurrentUser() user: AuthenticatedUser): AuthUserDto {
|
||||
return { email: user.email, name: user.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* 가드를 걸지 않는다 — 액세스 쿠키가 없거나 만료된 상태에서 부르는 엔드포인트다 (세션 복원).
|
||||
* 리프레시 쿠키만 검증한다.
|
||||
*/
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '토큰 회전 — 리프레시 쿠키만으로 두 쿠키를 새로 굽는다 (1회용)',
|
||||
description:
|
||||
'액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' +
|
||||
'이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' +
|
||||
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '회전 성공' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'리프레시 쿠키 없음·만료·위조·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 쿠키도 지운다',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다')
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
try {
|
||||
const result = await this.auth.refresh(
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
// 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
clearSessionCookies(res, this.settings);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다',
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
await this.auth.logout(readCookie(req, REFRESH_TOKEN_COOKIE));
|
||||
clearSessionCookies(res, this.settings);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
/**
|
||||
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
|
||||
*
|
||||
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
|
||||
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
|
||||
* 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다.
|
||||
*
|
||||
* 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ session: false }),
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([AuthSessionEntity]),
|
||||
MailModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
buildAuthSettings(
|
||||
(key) => config.get<string>(key),
|
||||
(message) => new Logger('AuthModule').warn(message),
|
||||
),
|
||||
},
|
||||
AuthService,
|
||||
AuthSessionRepository,
|
||||
AuthSessionCleanup,
|
||||
LoginAttemptService,
|
||||
JwtStrategy,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,723 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { hashRefreshToken } from './auth-session.policy';
|
||||
import type {
|
||||
AuthSessionRepository,
|
||||
RotateSessionCommand,
|
||||
} from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
} from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const PASSWORD = 'correct-horse-battery';
|
||||
const CLIENT_IP = '203.0.113.10';
|
||||
const DAY_SECONDS = 24 * 3600;
|
||||
|
||||
const mailSettings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set(['admin@arasystem.test']),
|
||||
};
|
||||
|
||||
const settings: AuthSettings = {
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 14 * DAY_SECONDS,
|
||||
secureCookies: false,
|
||||
};
|
||||
|
||||
type DecodedToken = AuthTokenPayload & {
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 세션 표 대역 — 실제 저장소와 같은 조건부 회전을 흉내 낸다 (조건이 맞을 때만 바꾸고 true).
|
||||
* 값은 복사해서 돌려준다 (호출한 쪽이 고쳐도 저장된 값이 바뀌지 않게 — DB 와 같다).
|
||||
*/
|
||||
class FakeSessions {
|
||||
readonly rows = new Map<string, AuthSessionEntity>();
|
||||
|
||||
readonly create = jest.fn((session: AuthSessionEntity) => {
|
||||
this.rows.set(session.id, { ...session });
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
readonly findById = jest.fn((id: string) => {
|
||||
const row = this.rows.get(id);
|
||||
return Promise.resolve(row ? { ...row } : null);
|
||||
});
|
||||
|
||||
readonly rotate = jest.fn((command: RotateSessionCommand) => {
|
||||
const row = this.rows.get(command.id);
|
||||
const alive =
|
||||
row !== undefined &&
|
||||
row.tokenHash === command.presentedHash &&
|
||||
row.revokedAt === null &&
|
||||
row.expiresAt > command.now &&
|
||||
row.absoluteExpiresAt > command.now;
|
||||
if (alive) {
|
||||
Object.assign(row, {
|
||||
tokenHash: command.nextHash,
|
||||
previousTokenHash: command.presentedHash,
|
||||
rotatedAt: command.now,
|
||||
expiresAt: command.nextExpiresAt,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(alive);
|
||||
});
|
||||
|
||||
readonly revoke = jest.fn(
|
||||
(id: string, reason: AuthSessionEntity['revokedReason'], now: Date) => {
|
||||
const row = this.rows.get(id);
|
||||
if (row && row.revokedAt === null) {
|
||||
Object.assign(row, { revokedAt: now, revokedReason: reason });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
only(): AuthSessionEntity {
|
||||
const [row] = [...this.rows.values()];
|
||||
if (!row) throw new Error('세션이 없다');
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 이행되지 않은 결과를 코드별로 센다 */
|
||||
function countCodes(results: PromiseSettledResult<unknown>[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const result of results) {
|
||||
const code =
|
||||
result.status === 'fulfilled'
|
||||
? 'ok'
|
||||
: result.reason instanceof BusinessException
|
||||
? result.reason.code
|
||||
: 'UNEXPECTED';
|
||||
counts[code] = (counts[code] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('AuthService', () => {
|
||||
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
|
||||
let attempts: LoginAttemptService;
|
||||
let sessions: FakeSessions;
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
|
||||
const build = (overrides: Partial<MailSettings> = {}) =>
|
||||
new AuthService(
|
||||
mail as unknown as MailProvider,
|
||||
{ ...mailSettings, ...overrides },
|
||||
settings,
|
||||
jwt,
|
||||
attempts,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
jwt.verify<DecodedToken>(token, { secret: settings.jwtSecret });
|
||||
|
||||
const sign = (payload: object, expiresIn: number = 3600) =>
|
||||
jwt.sign(payload, {
|
||||
secret: settings.jwtSecret,
|
||||
expiresIn,
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
|
||||
/** 로그인해서 받은 리프레시 토큰 */
|
||||
const loginToken = async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (!result.tokens) throw new Error('로그인은 항상 토큰을 준다');
|
||||
return result.tokens.refreshToken;
|
||||
};
|
||||
|
||||
/** 회전해서 받은 새 리프레시 토큰 */
|
||||
const rotateFrom = async (token: string) => {
|
||||
const result = await service.refresh(token);
|
||||
if (!result.tokens) throw new Error('회전이 아니라 유예로 끝났다');
|
||||
return result.tokens.refreshToken;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mail = {
|
||||
verifyCredentials: jest.fn().mockResolvedValue(ARA),
|
||||
getProfile: jest
|
||||
.fn()
|
||||
.mockImplementation((email: string) =>
|
||||
Promise.resolve({ email, name: '김아라' }),
|
||||
),
|
||||
};
|
||||
attempts = new LoginAttemptService();
|
||||
sessions = new FakeSessions();
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
logs = [];
|
||||
const record = (message: unknown) => {
|
||||
logs.push(String(message));
|
||||
};
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(record);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(record);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(record);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('로그인', () => {
|
||||
it('메일 엔진이 확인하면 서버에 세션을 만들고 두 토큰을 발급한다 — 세션에는 토큰 해시만 남는다', async () => {
|
||||
const result = await service.login(
|
||||
' Ara.Kim@AraSystem.test ',
|
||||
PASSWORD,
|
||||
CLIENT_IP,
|
||||
);
|
||||
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
|
||||
expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true });
|
||||
expect(result.user).toEqual({ email: ARA, name: '김아라' });
|
||||
|
||||
const tokens = result.tokens!;
|
||||
const access = decode(tokens.accessToken);
|
||||
const refresh = decode(tokens.refreshToken);
|
||||
expect(access).toMatchObject({ sub: ARA, typ: 'access', name: '김아라' });
|
||||
expect(access.exp - access.iat).toBe(settings.accessTtlSeconds);
|
||||
expect(refresh).toMatchObject({ sub: ARA, typ: 'refresh' });
|
||||
expect(refresh).not.toHaveProperty('name');
|
||||
expect(refresh.exp - refresh.iat).toBe(settings.refreshTtlSeconds);
|
||||
expect(tokens.refreshTtlSeconds).toBe(settings.refreshTtlSeconds);
|
||||
expect(access.jti).not.toBe(refresh.jti);
|
||||
|
||||
const row = sessions.only();
|
||||
expect(refresh.sid).toBe(row.id);
|
||||
expect(row.userEmail).toBe(ARA);
|
||||
expect(row.tokenHash).toBe(hashRefreshToken(tokens.refreshToken));
|
||||
expect(JSON.stringify(row)).not.toContain(tokens.refreshToken);
|
||||
// 절대 만료 30일 · 미사용 만료 14일
|
||||
expect(row.absoluteExpiresAt.getTime() - row.createdAt.getTime()).toBe(
|
||||
30 * DAY_SECONDS * 1000,
|
||||
);
|
||||
expect(row.expiresAt.getTime() - row.createdAt.getTime()).toBe(
|
||||
14 * DAY_SECONDS * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
|
||||
const error = await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
|
||||
|
||||
expect(error?.code).toBe('AUTH_004');
|
||||
expect(sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 도메인이 아닌 주소는 메일 엔진에 묻지 않고 같은 AUTH_004', async () => {
|
||||
const error = await failureOf(
|
||||
service.login('ara.kim@gmail.com', PASSWORD, CLIENT_IP),
|
||||
);
|
||||
|
||||
expect(error?.code).toBe('AUTH_004');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('게이트웨이 계정과 대리 접근 형식(%)은 로그인할 수 없다', async () => {
|
||||
for (const email of [
|
||||
'mail-gateway@arasystem.test',
|
||||
'ara.kim%mail-gateway@arasystem.test',
|
||||
]) {
|
||||
expect(
|
||||
(await failureOf(service.login(email, PASSWORD, CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('메일 관리자 주소는 비밀번호가 맞아도 메일 엔진에 묻지 않고 같은 AUTH_004 (대소문자 무관)', async () => {
|
||||
for (const email of ['admin@arasystem.test', ' Admin@ARASYSTEM.test ']) {
|
||||
const error = await failureOf(
|
||||
service.login(email, PASSWORD, CLIENT_IP),
|
||||
);
|
||||
|
||||
expect(error?.code).toBe('AUTH_004');
|
||||
expect(error?.context).toMatchObject({
|
||||
reason: 'not-employee-address',
|
||||
});
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('관리자 아이디를 바꾸면(MAIL_ADMIN_LOCAL_PART) admin@ 은 일반 직원처럼 로그인한다 — 옮겨 온 실제 메일함', async () => {
|
||||
service = build({
|
||||
blockedAccounts: new Set(['mail-admin@arasystem.test']),
|
||||
});
|
||||
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
|
||||
|
||||
const result = await service.login(
|
||||
'admin@arasystem.test',
|
||||
PASSWORD,
|
||||
CLIENT_IP,
|
||||
);
|
||||
|
||||
expect(result.user.email).toBe('admin@arasystem.test');
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login('mail-admin@arasystem.test', PASSWORD, CLIENT_IP),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_004');
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('MAIL_BLOCKED_ACCOUNTS 로 더 막은 계정도 같다', async () => {
|
||||
service = build({
|
||||
blockedAccounts: new Set([
|
||||
'admin@arasystem.test',
|
||||
'ops@arasystem.test',
|
||||
]),
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login('ops@arasystem.test', PASSWORD, CLIENT_IP),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_004');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('별칭으로 들어와도 엔진이 알려 준 정식 계정이 관리자면 AUTH_004 — 메일함을 열지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
|
||||
|
||||
const error = await failureOf(
|
||||
service.login('postmaster@arasystem.test', PASSWORD, CLIENT_IP),
|
||||
);
|
||||
|
||||
expect(error?.code).toBe('AUTH_004');
|
||||
expect(error?.context).toMatchObject({ reason: 'rejected' });
|
||||
expect(mail.getProfile).not.toHaveBeenCalled();
|
||||
expect(sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 AUTH_004 (원인을 드러내지 않는다)', async () => {
|
||||
mail.getProfile.mockRejectedValue(new BusinessException('AUTH_001'));
|
||||
|
||||
const error = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP));
|
||||
|
||||
expect(error?.code).toBe('AUTH_004');
|
||||
expect(error?.context).toMatchObject({ reason: 'mailbox-unavailable' });
|
||||
});
|
||||
|
||||
it('메일 설정이 없으면 MAIL_001 (비운영 기동 상태)', async () => {
|
||||
service = build({ domain: null });
|
||||
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code,
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('로그에는 마스킹한 주소만 남고 비밀번호·토큰은 남지 않는다', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
const failure = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP));
|
||||
|
||||
const everything = [...logs, JSON.stringify(failure?.context)].join('\n');
|
||||
expect(everything).toContain('a***@arasystem.test');
|
||||
expect(everything).not.toContain(ARA);
|
||||
expect(everything).not.toContain(PASSWORD);
|
||||
expect(everything).not.toContain(result.tokens!.accessToken);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 실패 한도는 엔진에 묻기 **전에** 차지한다 — 동시 요청이 "아직 남았다"를 함께 보고 모두 통과하지 못한다.
|
||||
* 엔진이 느린(80ms) 상황에서 실제 LoginAttemptService 로 확인한다 (리뷰 재현 시나리오).
|
||||
*/
|
||||
describe('로그인 실패 한도 — 동시 요청', () => {
|
||||
const N = 100;
|
||||
|
||||
beforeEach(() => {
|
||||
mail.verifyCredentials.mockImplementation(async () => {
|
||||
await delay(80);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it(`같은 주소로 틀린 비밀번호 ${N}건을 동시에 보내도 엔진 확인은 1번 — 나머지는 진행 중이라 REQ_001`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: N }, () =>
|
||||
service.login(ARA, 'wrong', '203.0.113.7'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(countCodes(results)).toEqual({ AUTH_004: 1, REQ_001: N - 1 });
|
||||
});
|
||||
|
||||
it('같은 주소의 동시 공격을 이어 가도 엔진 확인은 주소 한도(10)를 넘지 않고, 그 뒤엔 맞는 비밀번호도 REQ_001', async () => {
|
||||
// 다른 IP 로 나눠 IP 한도에 걸리지 않게 한다 — 주소 한도만 본다
|
||||
for (let round = 0; round < 15; round += 1) {
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
service.login(ARA, 'wrong', `198.51.100.${round * 10 + i}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
);
|
||||
|
||||
mail.verifyCredentials.mockResolvedValue(ARA);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.1')))?.code,
|
||||
).toBe('REQ_001');
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it(`같은 IP 에서 서로 다른 주소 ${N}건을 동시에 보내면 엔진 확인은 IP 한도(20)번뿐`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
service.login(`guess${i}@arasystem.test`, 'wrong', '203.0.113.8'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
);
|
||||
expect(countCodes(results)).toEqual({
|
||||
AUTH_004: LOGIN_IP_FAILURE_LIMIT,
|
||||
REQ_001: N - LOGIN_IP_FAILURE_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it(`서로 다른 IP ${N}개로 한 주소를 동시에 찔러도 엔진 확인은 1번`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
service.login(ARA, 'wrong', `10.0.${Math.floor(i / 250)}.${i % 250}`),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(countCodes(results)).toEqual({ AUTH_004: 1, REQ_001: N - 1 });
|
||||
});
|
||||
|
||||
it('성공한 로그인은 세지 않는다 — 같은 IP(사무실 NAT)에서 한도보다 많이 성공해도 막히지 않는다', async () => {
|
||||
mail.verifyCredentials.mockImplementation((email: string) =>
|
||||
Promise.resolve(email),
|
||||
);
|
||||
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
|
||||
await service.login(`staff${i}@arasystem.test`, PASSWORD, CLIENT_IP);
|
||||
}
|
||||
expect(sessions.create).toHaveBeenCalledTimes(LOGIN_IP_FAILURE_LIMIT * 2);
|
||||
});
|
||||
|
||||
it('메일 엔진 장애(MAIL_001)는 실패로 세지 않는다', async () => {
|
||||
mail.verifyCredentials.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 2; i += 1) {
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code,
|
||||
).toBe('MAIL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('IP 를 알 수 없는 요청은 한 묶음으로 센다', async () => {
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: LOGIN_IP_FAILURE_LIMIT }, (_, i) =>
|
||||
service.login(`guess${i}@arasystem.test`, 'wrong', ''),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await failureOf(service.login('new@arasystem.test', 'wrong', '')))
|
||||
?.context,
|
||||
).toMatchObject({ scope: 'ip', ip: 'unknown' });
|
||||
});
|
||||
|
||||
it('사내 도메인이 아닌 주소는 주소 한도에 넣지 않는다 — IP 를 바꿔 가며 계속 시도해도 늘 AUTH_004', async () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 5; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login('ara.kim@gmail.com', 'wrong', `198.51.100.${i}`),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('메일 관리자 주소도 주소 한도에 넣지 않는다 — 관리자 주소로 한도를 채워 잠그는 일이 없다 (IP 한도에만 센다)', async () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 5; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login('admin@arasystem.test', 'wrong', `198.51.100.${i}`),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 도메인이 아닌 주소도 IP 한도에는 센다', async () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
await failureOf(
|
||||
service.login(`someone${i}@gmail.com`, 'wrong', '203.0.113.9'),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '203.0.113.9')))?.context,
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('리프레시 (서버 세션 · 1회용 회전)', () => {
|
||||
it('리프레시 토큰만으로 계정을 다시 확인하고 두 토큰을 새로 발급한다 — 이전 토큰은 폐기된다', async () => {
|
||||
const first = await loginToken();
|
||||
|
||||
const result = await service.refresh(first);
|
||||
|
||||
expect(mail.getProfile).toHaveBeenLastCalledWith(ARA, { fresh: true });
|
||||
expect(result.user).toEqual({ email: ARA, name: '김아라' });
|
||||
const next = result.tokens!.refreshToken;
|
||||
expect(next).not.toBe(first);
|
||||
expect(decode(result.tokens!.accessToken)).toMatchObject({
|
||||
typ: 'access',
|
||||
});
|
||||
|
||||
const row = sessions.only();
|
||||
expect(decode(next).sid).toBe(row.id);
|
||||
expect(row.tokenHash).toBe(hashRefreshToken(next));
|
||||
expect(row.previousTokenHash).toBe(hashRefreshToken(first));
|
||||
});
|
||||
|
||||
it('회전 직후 바로 이전 토큰이 오면(다른 탭의 동시 갱신) 세션을 끊지 않고 새 토큰 없이 성공한다', async () => {
|
||||
const first = await loginToken();
|
||||
const next = await rotateFrom(first);
|
||||
|
||||
const result = await service.refresh(first);
|
||||
|
||||
expect(result.tokens).toBeNull();
|
||||
expect(result.user.email).toBe(ARA);
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
// 이긴 쪽의 토큰은 그대로 쓸 수 있다
|
||||
await expect(rotateFrom(next)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('같은 토큰으로 동시에 갱신하면 하나만 회전하고 나머지는 유예로 끝난다 (세션 유지)', async () => {
|
||||
const first = await loginToken();
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => service.refresh(first)),
|
||||
);
|
||||
|
||||
expect(results.filter((result) => result.tokens !== null)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('이미 쓴 토큰(두 번 이상 지난 것)이 오면 세션 전체를 끊는다 — 최신 토큰도 더는 못 쓴다', async () => {
|
||||
const first = await loginToken();
|
||||
const second = await rotateFrom(first);
|
||||
const third = await rotateFrom(second);
|
||||
|
||||
const reuse = await failureOf(service.refresh(first));
|
||||
|
||||
expect(reuse?.code).toBe('AUTH_001');
|
||||
expect(reuse?.context).toMatchObject({ reason: 'session-reused' });
|
||||
expect(sessions.only()).toMatchObject({
|
||||
revokedReason: 'refresh-token-reused',
|
||||
});
|
||||
expect((await failureOf(service.refresh(third)))?.context).toMatchObject({
|
||||
reason: 'session-revoked',
|
||||
});
|
||||
expect(logs.join('\n')).toMatch(/\[AUTH_001\].*세션을 끊었습니다/);
|
||||
});
|
||||
|
||||
it('바로 이전 토큰이라도 유예 시간이 지난 뒤 오면 재사용으로 보고 세션을 끊는다', async () => {
|
||||
const first = await loginToken();
|
||||
await rotateFrom(first);
|
||||
// 시간이 흐른 것처럼 회전 시각을 유예 시간보다 앞으로 돌린다
|
||||
const row = sessions.only();
|
||||
row.rotatedAt = new Date(Date.now() - REFRESH_REUSE_GRACE_MS - 1);
|
||||
|
||||
expect((await failureOf(service.refresh(first)))?.code).toBe('AUTH_001');
|
||||
expect(row.revokedReason).toBe('refresh-token-reused');
|
||||
});
|
||||
|
||||
it('만료된 세션(미사용 14일·절대 30일)은 AUTH_001', async () => {
|
||||
const token = await loginToken();
|
||||
sessions.only().expiresAt = new Date(Date.now() - 1);
|
||||
|
||||
expect((await failureOf(service.refresh(token)))?.context).toMatchObject({
|
||||
reason: 'session-expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('절대 만료가 가까우면 새 리프레시 토큰·쿠키 수명이 그 안으로 줄고, 액세스 토큰도 그보다 오래 살지 않는다', async () => {
|
||||
const token = await loginToken();
|
||||
sessions.only().absoluteExpiresAt = new Date(Date.now() + 600_000);
|
||||
|
||||
const result = await service.refresh(token);
|
||||
|
||||
const { refreshTtlSeconds, refreshToken, accessToken } = result.tokens!;
|
||||
expect(refreshTtlSeconds).toBeLessThanOrEqual(600);
|
||||
expect(refreshTtlSeconds).toBeGreaterThan(590);
|
||||
const access = decode(accessToken);
|
||||
expect(access.exp - access.iat).toBeLessThanOrEqual(600);
|
||||
const refresh = decode(refreshToken);
|
||||
expect(refresh.exp - refresh.iat).toBe(refreshTtlSeconds);
|
||||
});
|
||||
|
||||
it('계정이 사라졌으면 세션을 끊고 AUTH_001', async () => {
|
||||
const token = await loginToken();
|
||||
mail.getProfile.mockRejectedValueOnce(new BusinessException('AUTH_001'));
|
||||
|
||||
expect((await failureOf(service.refresh(token)))?.code).toBe('AUTH_001');
|
||||
expect(sessions.only().revokedReason).toBe('account-unavailable');
|
||||
});
|
||||
|
||||
it('메일 엔진 장애면 MAIL_001 그대로 — 회전하지 않았으므로 같은 토큰으로 다시 시도할 수 있다', async () => {
|
||||
const token = await loginToken();
|
||||
mail.getProfile.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
|
||||
expect((await failureOf(service.refresh(token)))?.code).toBe('MAIL_001');
|
||||
expect(sessions.rotate).not.toHaveBeenCalled();
|
||||
await expect(rotateFrom(token)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('쿠키가 없거나·위조·만료·종류가 다르거나·세션 id 가 없으면 AUTH_001 이고 엔진·DB 에 묻지 않는다', async () => {
|
||||
const sid = '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10';
|
||||
const forged = jwt.sign(
|
||||
{ sub: ARA, typ: 'refresh', sid },
|
||||
{ secret: 'another-secret-another-secret-12' },
|
||||
);
|
||||
const expired = sign({ sub: ARA, typ: 'refresh', sid }, -10);
|
||||
const accessToken = sign({ sub: ARA, typ: 'access', name: '김아라' });
|
||||
const withoutSession = sign({ sub: ARA, typ: 'refresh' });
|
||||
const badSessionId = sign({ sub: ARA, typ: 'refresh', sid: "1' OR '1" });
|
||||
const noneAlg = `${Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url')}.${Buffer.from(`{"sub":"${ARA}","typ":"refresh","sid":"${sid}"}`).toString('base64url')}.`;
|
||||
|
||||
for (const token of [
|
||||
undefined,
|
||||
'garbage',
|
||||
forged,
|
||||
expired,
|
||||
accessToken,
|
||||
withoutSession,
|
||||
badSessionId,
|
||||
noneAlg,
|
||||
]) {
|
||||
expect((await failureOf(service.refresh(token)))?.code).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
}
|
||||
expect(mail.getProfile).not.toHaveBeenCalled();
|
||||
expect(sessions.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('서명은 맞지만 DB 에 없는 세션이면 AUTH_001 (정리된 세션 등)', async () => {
|
||||
const token = sign({
|
||||
sub: ARA,
|
||||
typ: 'refresh',
|
||||
sid: '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10',
|
||||
});
|
||||
|
||||
expect((await failureOf(service.refresh(token)))?.context).toMatchObject({
|
||||
reason: 'session-missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('회전 조건이 같은데 바뀌지 않으면(서버 쪽 불일치) SYS_001 — 세션을 끊지 않아 같은 쿠키로 다시 시도할 수 있다', async () => {
|
||||
const token = await loginToken();
|
||||
// 저장소가 "현재 토큰"으로 보이는데도 회전하지 않는 상황을 흉내 낸다
|
||||
sessions.rotate.mockResolvedValueOnce(false);
|
||||
|
||||
const error = await failureOf(service.refresh(token));
|
||||
|
||||
expect(error?.code).toBe('SYS_001');
|
||||
expect(error?.context).toMatchObject({ reason: 'rotation-conflict' });
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
// 세션은 그대로 — 같은 토큰으로 다시 회전된다
|
||||
await expect(rotateFrom(token)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
describe('로그아웃', () => {
|
||||
it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => {
|
||||
const token = await loginToken();
|
||||
|
||||
await service.logout(token);
|
||||
|
||||
expect(sessions.only().revokedReason).toBe('logout');
|
||||
expect((await failureOf(service.refresh(token)))?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('리프레시 토큰이 만료됐어도(서명은 맞으면) 그 세션을 끊는다', async () => {
|
||||
await loginToken();
|
||||
const { id } = sessions.only();
|
||||
const expired = sign({ sub: ARA, typ: 'refresh', sid: id }, -10);
|
||||
|
||||
await service.logout(expired);
|
||||
|
||||
expect(sessions.only().revokedReason).toBe('logout');
|
||||
});
|
||||
|
||||
it('토큰이 없거나 위조여도 예외 없이 끝난다 (항상 200)', async () => {
|
||||
await expect(service.logout(undefined)).resolves.toBeUndefined();
|
||||
await expect(service.logout('garbage')).resolves.toBeUndefined();
|
||||
expect(sessions.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('세션 폐기가 실패해도 예외 없이 끝나고 오류 로그를 남긴다', async () => {
|
||||
const token = await loginToken();
|
||||
sessions.revoke.mockRejectedValueOnce(new Error('connection refused'));
|
||||
|
||||
await expect(service.logout(token)).resolves.toBeUndefined();
|
||||
expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailAccountProfile } from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import {
|
||||
classifyRefresh,
|
||||
hashRefreshToken,
|
||||
nextIdleExpiry,
|
||||
type RefreshState,
|
||||
} from './auth-session.policy';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import {
|
||||
JWT_ALGORITHM,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
SESSION_ABSOLUTE_TTL_SECONDS,
|
||||
} from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { isEmployeeAddress, normalizeEmail } from './employee-address';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
export interface AuthResult {
|
||||
readonly user: AuthenticatedUser;
|
||||
/**
|
||||
* 구울 쿠키. 동시 갱신 유예(같은 세션의 다른 요청이 방금 회전함)면 null —
|
||||
* 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다.
|
||||
*/
|
||||
readonly tokens: SessionTokens | null;
|
||||
}
|
||||
|
||||
/** 리프레시 토큰에서 꺼낸 값 */
|
||||
interface RefreshClaims {
|
||||
readonly sub: string;
|
||||
readonly sid: string;
|
||||
}
|
||||
|
||||
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function toUser(profile: MailAccountProfile): AuthenticatedUser {
|
||||
return { email: profile.email, name: profile.name };
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 로그인 — 직원 계정 = 메일 계정.
|
||||
* 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다.
|
||||
* 로그인하면 서버에 세션이 생기고(auth_sessions), 리프레시 토큰은 1회용으로 회전한다 (계약 1절 "세션 규칙").
|
||||
*
|
||||
* 로그에는 비밀번호·토큰을 절대 남기지 않고, 주소는 마스킹한다 (.claude/rules/logging.md).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly mail: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md).
|
||||
* 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService).
|
||||
* 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다).
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
clientIp: string,
|
||||
): Promise<AuthResult> {
|
||||
const address = normalizeEmail(email);
|
||||
if (!this.mailSettings.domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
|
||||
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
|
||||
// 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) —
|
||||
// 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다
|
||||
const ticket = this.attempts.begin({
|
||||
email: this.isLoginAddress(address) ? address : null,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let profile: MailAccountProfile;
|
||||
try {
|
||||
profile = await this.authenticate(address, password);
|
||||
ticket.succeed();
|
||||
} catch (error) {
|
||||
// AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_004') {
|
||||
ticket.fail();
|
||||
} else {
|
||||
ticket.abort();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile);
|
||||
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
|
||||
return { user: toUser(profile), tokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
*
|
||||
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
|
||||
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
|
||||
* - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001)
|
||||
* - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다
|
||||
*/
|
||||
async refresh(refreshToken: string | undefined): Promise<AuthResult> {
|
||||
if (!refreshToken) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'refresh-token-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await this.verifyRefreshToken(refreshToken, false);
|
||||
const presentedHash = hashRefreshToken(refreshToken);
|
||||
const now = new Date();
|
||||
const session = await this.sessions.findById(claims.sid);
|
||||
|
||||
return this.resolveRefresh(
|
||||
this.classify(session, claims, presentedHash, now),
|
||||
session,
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
*/
|
||||
async logout(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
|
||||
let claims: RefreshClaims;
|
||||
try {
|
||||
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
|
||||
claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sessions.revoke(claims.sid, 'logout', new Date());
|
||||
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인할 수 있는 주소인가 — 사내 도메인 주소이고 게이트웨이·관리자(막는 계정)가 아니어야 한다.
|
||||
* 관리자 주소는 엔진에 묻기 전에 막는다: 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게.
|
||||
*/
|
||||
private isLoginAddress(address: string): boolean {
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
return (
|
||||
domain !== null &&
|
||||
isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)
|
||||
);
|
||||
}
|
||||
|
||||
private async authenticate(
|
||||
address: string,
|
||||
password: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
if (!this.isLoginAddress(address)) {
|
||||
throw this.loginFailed(address, 'not-employee-address');
|
||||
}
|
||||
|
||||
const account = await this.mail.verifyCredentials(address, password);
|
||||
// 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다
|
||||
if (!account || !this.isLoginAddress(account)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
return this.loadProfileForLogin(account, address);
|
||||
}
|
||||
|
||||
private classify(
|
||||
session: AuthSessionEntity | null,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): RefreshState {
|
||||
return classifyRefresh(
|
||||
session,
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefresh(
|
||||
state: RefreshState,
|
||||
session: AuthSessionEntity | null,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): Promise<AuthResult> {
|
||||
if (state === 'current' && session) {
|
||||
return this.rotate(session, claims, presentedHash, now);
|
||||
}
|
||||
|
||||
if (state === 'grace') {
|
||||
const profile = await this.mail.getProfile(claims.sub);
|
||||
return { user: toUser(profile), tokens: null };
|
||||
}
|
||||
|
||||
if (state === 'reused') {
|
||||
await this.sessions.revoke(claims.sid, 'refresh-token-reused', now);
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 이미 쓴 리프레시 토큰이 다시 와서 세션을 끊었습니다 (탈취 의심): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: `session-${state}`, sid: claims.sid },
|
||||
});
|
||||
}
|
||||
|
||||
private async rotate(
|
||||
session: AuthSessionEntity,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): Promise<AuthResult> {
|
||||
const profile = await this.loadProfileForRefresh(claims, now);
|
||||
const expiresAt = nextIdleExpiry(
|
||||
now,
|
||||
this.settings.refreshTtlSeconds,
|
||||
session.absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, claims.sid, expiresAt, now);
|
||||
|
||||
const rotated = await this.sessions.rotate({
|
||||
id: claims.sid,
|
||||
presentedHash,
|
||||
nextHash: hashRefreshToken(tokens.refreshToken),
|
||||
nextExpiresAt: expiresAt,
|
||||
now,
|
||||
});
|
||||
if (rotated) return { user: toUser(profile), tokens };
|
||||
|
||||
// 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다
|
||||
const latest = await this.sessions.findById(claims.sid);
|
||||
const state = this.classify(latest, claims, presentedHash, now);
|
||||
if (state === 'grace') return { user: toUser(profile), tokens: null };
|
||||
if (state === 'current') {
|
||||
// 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다.
|
||||
// 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고,
|
||||
// 클라이언트는 같은 리프레시 쿠키로 다시 시도할 수 있다. 5xx 라 예외 필터가 error 로 남긴다
|
||||
throw new BusinessException('SYS_001', {
|
||||
context: { reason: 'rotation-conflict', sid: claims.sid },
|
||||
});
|
||||
}
|
||||
return this.resolveRefresh(state, latest, claims, presentedHash, now);
|
||||
}
|
||||
|
||||
/** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */
|
||||
private async loadProfileForRefresh(
|
||||
claims: RefreshClaims,
|
||||
now: Date,
|
||||
): Promise<MailAccountProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(claims.sub, { fresh: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
await this.sessions.revoke(claims.sid, 'account-unavailable', now);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 같은 실패로 응답한다 (원인을 드러내지 않는다) */
|
||||
private async loadProfileForLogin(
|
||||
account: string,
|
||||
address: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(account, { fresh: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
throw this.loginFailed(address, 'mailbox-unavailable');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
): Promise<SessionTokens> {
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
const absoluteExpiresAt = new Date(
|
||||
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
|
||||
);
|
||||
const expiresAt = nextIdleExpiry(
|
||||
now,
|
||||
this.settings.refreshTtlSeconds,
|
||||
absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, id, expiresAt, now);
|
||||
|
||||
await this.sessions.create({
|
||||
id,
|
||||
userEmail: profile.email,
|
||||
tokenHash: hashRefreshToken(tokens.refreshToken),
|
||||
previousTokenHash: null,
|
||||
rotatedAt: null,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async verifyRefreshToken(
|
||||
token: string,
|
||||
ignoreExpiration: boolean,
|
||||
): Promise<RefreshClaims> {
|
||||
let payload: AuthTokenPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
|
||||
secret: this.settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
ignoreExpiration,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'invalid-refresh-token',
|
||||
cause: errorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { typ, sub, sid } = payload;
|
||||
if (
|
||||
typ !== 'refresh' ||
|
||||
typeof sub !== 'string' ||
|
||||
typeof sid !== 'string' ||
|
||||
!SESSION_ID_PATTERN.test(sid)
|
||||
) {
|
||||
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'not-a-refresh-token' },
|
||||
});
|
||||
}
|
||||
return { sub, sid };
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
|
||||
* (세션보다 오래 사는 토큰을 만들지 않는다).
|
||||
*/
|
||||
private async signTokens(
|
||||
profile: MailAccountProfile,
|
||||
sessionId: string,
|
||||
refreshExpiresAt: Date,
|
||||
now: Date,
|
||||
): Promise<SessionTokens> {
|
||||
const { jwtSecret, accessTtlSeconds } = this.settings;
|
||||
const refreshTtlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
|
||||
);
|
||||
const access: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'access',
|
||||
name: profile.name,
|
||||
};
|
||||
const refresh: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'refresh',
|
||||
sid: sessionId,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwt.signAsync(access, {
|
||||
secret: jwtSecret,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
|
||||
jwtid: randomUUID(),
|
||||
}),
|
||||
this.jwt.signAsync(refresh, {
|
||||
secret: jwtSecret,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
expiresIn: refreshTtlSeconds,
|
||||
jwtid: randomUUID(),
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken, refreshTtlSeconds };
|
||||
}
|
||||
|
||||
private loginFailed(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_004', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { buildAuthSettings } from './auth.settings';
|
||||
|
||||
const reader =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
describe('buildAuthSettings', () => {
|
||||
it('env 의 서명 키와 만료 기간을 초 단위로 쓴다', () => {
|
||||
const warn = jest.fn();
|
||||
|
||||
const settings = buildAuthSettings(
|
||||
reader({
|
||||
NODE_ENV: 'development',
|
||||
JWT_SECRET: 'configured-secret-value',
|
||||
JWT_ACCESS_EXPIRES_IN: '15m',
|
||||
JWT_REFRESH_EXPIRES_IN: '7d',
|
||||
}),
|
||||
warn,
|
||||
);
|
||||
|
||||
expect(settings).toEqual({
|
||||
jwtSecret: 'configured-secret-value',
|
||||
accessTtlSeconds: 15 * 60,
|
||||
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
||||
secureCookies: false,
|
||||
});
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('만료 기간이 없으면 계약 기본값(1h · 14d)', () => {
|
||||
const settings = buildAuthSettings(
|
||||
reader({ JWT_SECRET: 'configured-secret-value' }),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(settings.accessTtlSeconds).toBe(60 * 60);
|
||||
expect(settings.refreshTtlSeconds).toBe(14 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('비운영에서 서명 키가 없으면 임시 키로 기동하고 경고한다 (CI e2e)', () => {
|
||||
const warn = jest.fn<void, [string]>();
|
||||
|
||||
const first = buildAuthSettings(reader({ NODE_ENV: 'test' }), warn);
|
||||
const second = buildAuthSettings(
|
||||
reader({ NODE_ENV: 'test', JWT_SECRET: ' ' }),
|
||||
warn,
|
||||
);
|
||||
|
||||
expect(first.jwtSecret.length).toBeGreaterThanOrEqual(64);
|
||||
expect(second.jwtSecret).not.toBe(first.jwtSecret);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn.mock.calls[0][0]).toContain('임시 서명 키');
|
||||
});
|
||||
|
||||
it('운영에서 서명 키가 없으면 기동하지 않는다', () => {
|
||||
expect(() =>
|
||||
buildAuthSettings(reader({ NODE_ENV: 'production' }), jest.fn()),
|
||||
).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('운영에서만 Secure 쿠키를 쓴다', () => {
|
||||
const settings = buildAuthSettings(
|
||||
reader({
|
||||
NODE_ENV: 'production',
|
||||
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
|
||||
}),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(settings.secureCookies).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { parseDurationSeconds } from '@/shared/utils/duration';
|
||||
import {
|
||||
DEFAULT_ACCESS_TTL_SECONDS,
|
||||
DEFAULT_REFRESH_TTL_SECONDS,
|
||||
} from './auth.constants';
|
||||
|
||||
/**
|
||||
* 인증 설정 — 백엔드 고유 시크릿(`backend/.env.{APP_ENV}`)에서 읽는다.
|
||||
* 쿠키 Max-Age 와 토큰 만료가 같은 값에서 나오도록 초 단위로 한 번만 해석한다.
|
||||
*/
|
||||
export const AUTH_SETTINGS = Symbol('AUTH_SETTINGS');
|
||||
|
||||
export interface AuthSettings {
|
||||
readonly jwtSecret: string;
|
||||
readonly accessTtlSeconds: number;
|
||||
readonly refreshTtlSeconds: number;
|
||||
/** 운영에서만 Secure 쿠키 (개발은 http://localhost) */
|
||||
readonly secureCookies: boolean;
|
||||
}
|
||||
|
||||
/** JWT_SECRET 이 없을 때 만드는 임시 키 길이 (바이트) */
|
||||
const TEMPORARY_SECRET_BYTES = 48;
|
||||
|
||||
/**
|
||||
* env 값을 읽어 인증 설정을 만든다.
|
||||
* JWT_SECRET 이 없으면 **비운영에서만** 무작위 임시 키로 기동하고 경고를 남긴다
|
||||
* (재시작하면 모든 세션이 끊긴다). 운영은 env 검증이 기동을 막지만 여기서도 한 번 더 막는다.
|
||||
*/
|
||||
export function buildAuthSettings(
|
||||
read: (key: string) => string | undefined,
|
||||
warn: (message: string) => void,
|
||||
): AuthSettings {
|
||||
const isProduction = read('NODE_ENV')?.trim() === 'production';
|
||||
let jwtSecret = read('JWT_SECRET')?.trim();
|
||||
|
||||
if (!jwtSecret) {
|
||||
if (isProduction) {
|
||||
throw new Error('JWT_SECRET 가 설정되지 않았습니다. (운영 환경 필수)');
|
||||
}
|
||||
jwtSecret = randomBytes(TEMPORARY_SECRET_BYTES).toString('base64url');
|
||||
warn(
|
||||
'JWT_SECRET 이 없어 임시 서명 키로 기동합니다. 재시작하면 모든 로그인 세션이 끊깁니다. (개발·테스트 전용 — 운영은 기동하지 않는다)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
jwtSecret,
|
||||
accessTtlSeconds:
|
||||
parseDurationSeconds(read('JWT_ACCESS_EXPIRES_IN')) ??
|
||||
DEFAULT_ACCESS_TTL_SECONDS,
|
||||
refreshTtlSeconds:
|
||||
parseDurationSeconds(read('JWT_REFRESH_EXPIRES_IN')) ??
|
||||
DEFAULT_REFRESH_TTL_SECONDS,
|
||||
secureCookies: isProduction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
|
||||
/** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */
|
||||
export class AuthUserDto implements AuthenticatedUser {
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소',
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '표시 이름', example: '김아라' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** POST /api/auth/login — 직원 메일 계정 */
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소 (사내 도메인)',
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@MaxLength(MAX_EMAIL_LENGTH, { message: '이메일이 너무 깁니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다',
|
||||
format: 'password',
|
||||
example: 'your-password',
|
||||
})
|
||||
@IsString({ message: '비밀번호를 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' })
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
isEmployeeAddress,
|
||||
isLocalPart,
|
||||
normalizeEmail,
|
||||
} from './employee-address';
|
||||
|
||||
const DOMAIN = 'arasystem.test';
|
||||
const GATEWAY = 'mail-gateway@arasystem.test';
|
||||
|
||||
describe('로그인 주소 정책', () => {
|
||||
it('사내 도메인의 직원 주소를 받는다', () => {
|
||||
for (const email of [
|
||||
'ara.kim@arasystem.test',
|
||||
'doyun.lee@arasystem.test',
|
||||
'a@arasystem.test',
|
||||
'team_lead-01@arasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('사내 도메인이 아니면 거절한다 (하위 도메인·비슷한 도메인 포함)', () => {
|
||||
for (const email of [
|
||||
'ara.kim@gmail.com',
|
||||
'ara.kim@mail.arasystem.test',
|
||||
'ara.kim@arasystem.test.evil.example',
|
||||
'ara.kim@xarasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('게이트웨이 계정은 직접 로그인할 수 없다', () => {
|
||||
expect(isEmployeeAddress(GATEWAY, DOMAIN, GATEWAY)).toBe(false);
|
||||
});
|
||||
|
||||
it('막는 계정(메일 관리자 등)은 사내 도메인 주소여도 로그인할 수 없다 — 다른 직원은 그대로', () => {
|
||||
const blocked = new Set(['admin@arasystem.test', 'ops@arasystem.test']);
|
||||
|
||||
expect(
|
||||
isEmployeeAddress('admin@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmployeeAddress('ops@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmployeeAddress('ara.kim@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('대리 접근 구분자(%)·콜론 등이 섞인 주소는 거절한다', () => {
|
||||
for (const email of [
|
||||
'ara.kim%mail-gateway@arasystem.test',
|
||||
'ara:kim@arasystem.test',
|
||||
'"ara kim"@arasystem.test',
|
||||
'.ara@arasystem.test',
|
||||
'ara.@arasystem.test',
|
||||
'@arasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('로컬 부분(아이디) 형식 — 직원 주소와 같은 규칙, 대소문자는 가리지 않는다 (관리자 아이디 검증에도 쓴다)', () => {
|
||||
for (const value of ['admin', 'Mail-Admin', 'a', 'ara_ops.1']) {
|
||||
expect(isLocalPart(value)).toBe(true);
|
||||
}
|
||||
for (const value of [
|
||||
'admin@arasystem.test',
|
||||
'admin%gw',
|
||||
'ad min',
|
||||
'.admin',
|
||||
'',
|
||||
'a'.repeat(65),
|
||||
]) {
|
||||
expect(isLocalPart(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('비교 전에 공백을 지우고 소문자로 맞춘다', () => {
|
||||
expect(normalizeEmail(' Ara.Kim@AraSystem.TEST ')).toBe(
|
||||
'ara.kim@arasystem.test',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 로그인 주소 정책 (순수 함수).
|
||||
* 직원 계정 = 사내 도메인의 메일 계정. 그 밖의 주소는 메일 엔진에 묻지도 않고 거절한다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 사내 주소의 로컬 부분 — 영문 소문자·숫자·`.`·`_`·`-` (앞뒤는 영문·숫자, 64자 이하).
|
||||
* 특히 `%`·`:` 를 막는다. Stalwart 는 `<대상>%<계정>` 을 대리 접근으로 해석하므로,
|
||||
* 로그인 주소에 `%` 가 섞이면 백엔드(신뢰 대역)를 통해 대리 접근 로그인이 시도될 수 있다.
|
||||
*/
|
||||
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
/**
|
||||
* 사내 주소의 로컬 부분(아이디) 형식인가 — 대소문자는 가리지 않는다 (주소는 소문자로 맞춰 비교한다).
|
||||
* 메일 엔진 관리자 아이디(MAIL_ADMIN_LOCAL_PART) 검증에도 쓴다 — env.validation.ts
|
||||
*/
|
||||
export function isLocalPart(value: string): boolean {
|
||||
return LOCAL_PART_PATTERN.test(value.toLowerCase());
|
||||
}
|
||||
|
||||
/** 비교용 정규화 — 앞뒤 공백 제거 + 소문자 */
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인할 수 있는 직원 주소인지 확인한다.
|
||||
* - 사내 도메인(MAIL_DOMAIN) 주소만
|
||||
* - 게이트웨이 서비스 계정은 직접 로그인할 수 없다
|
||||
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
|
||||
*/
|
||||
export function isEmployeeAddress(
|
||||
email: string,
|
||||
domain: string,
|
||||
gatewayUser: string | null,
|
||||
blockedAccounts: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
const at = email.lastIndexOf('@');
|
||||
if (at <= 0) return false;
|
||||
|
||||
const localPart = email.slice(0, at);
|
||||
const host = email.slice(at + 1);
|
||||
|
||||
return (
|
||||
host === domain &&
|
||||
LOCAL_PART_PATTERN.test(localPart) &&
|
||||
email !== gatewayUser &&
|
||||
!blockedAccounts.has(email)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
FixedWindowCounter,
|
||||
type ReserveResult,
|
||||
type WindowSlot,
|
||||
} from './fixed-window-counter';
|
||||
|
||||
const T0 = 1_000_000;
|
||||
const WINDOW = 60_000;
|
||||
|
||||
/** 자리를 차지했다고 확인하고 그 자리를 꺼낸다 */
|
||||
function slotOf(result: ReserveResult): WindowSlot {
|
||||
if (!result.ok) throw new Error(`자리를 얻지 못했다: ${result.reason}`);
|
||||
return result.slot;
|
||||
}
|
||||
|
||||
describe('FixedWindowCounter', () => {
|
||||
it('한도까지 자리를 내주고, 한도에 닿으면 limit 으로 거절', () => {
|
||||
const counter = new FixedWindowCounter(3, WINDOW, 100);
|
||||
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0)).toEqual({ ok: false, reason: 'limit' });
|
||||
expect(counter.isExhausted('a', T0)).toBe(true);
|
||||
// 다른 키는 따로 센다
|
||||
expect(counter.reserve('b', T0).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('구간은 첫 기록부터 고정이다 — 기록할 때마다 늘어나지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(2, WINDOW, 100);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.reserve('a', T0 + WINDOW - 1);
|
||||
expect(counter.reserve('a', T0 + WINDOW - 1).ok).toBe(false);
|
||||
|
||||
// 첫 기록 + WINDOW 가 지나면 0 부터 다시 센다
|
||||
expect(counter.count('a', T0 + WINDOW)).toBe(0);
|
||||
expect(counter.reserve('a', T0 + WINDOW).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('돌려준 자리는 다시 쓸 수 있다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 100);
|
||||
|
||||
const slot = slotOf(counter.reserve('a', T0));
|
||||
expect(counter.reserve('a', T0).ok).toBe(false);
|
||||
|
||||
counter.release(slot, T0 + 1);
|
||||
expect(counter.count('a', T0 + 1)).toBe(0);
|
||||
expect(counter.reserve('a', T0 + 1).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('구간이 바뀐 뒤에 돌려주면 새 구간의 수를 건드리지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 100);
|
||||
|
||||
const oldSlot = slotOf(counter.reserve('a', T0));
|
||||
counter.reserve('a', T0 + WINDOW); // 새 구간의 첫 기록
|
||||
counter.release(oldSlot, T0 + WINDOW + 1);
|
||||
|
||||
expect(counter.count('a', T0 + WINDOW + 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clear 는 그 키의 기록을 지운다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 100);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.clear('a');
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('sweep 은 끝난 구간만 지운다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 100);
|
||||
|
||||
counter.reserve('old', T0);
|
||||
counter.reserve('new', T0 + 10_000);
|
||||
|
||||
expect(counter.sweep(T0 + WINDOW)).toEqual({
|
||||
removed: 1,
|
||||
evicted: 0,
|
||||
rejected: 0,
|
||||
});
|
||||
expect(counter.size).toBe(1);
|
||||
expect(counter.count('new', T0 + WINDOW)).toBe(1);
|
||||
});
|
||||
|
||||
describe('키 개수 상한', () => {
|
||||
it('상한에 닿으면 가장 먼저 끝나는 잠기지 않은 구간을 밀어내고, 밀어낸 수를 알려 준다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 3);
|
||||
|
||||
counter.reserve('k1', T0);
|
||||
counter.reserve('k2', T0 + 1);
|
||||
counter.reserve('k3', T0 + 2);
|
||||
expect(counter.reserve('k4', T0 + 3).ok).toBe(true);
|
||||
|
||||
expect(counter.size).toBe(3);
|
||||
expect(counter.count('k1', T0 + 3)).toBe(0);
|
||||
expect(counter.count('k4', T0 + 3)).toBe(1);
|
||||
expect(counter.sweep(T0 + 3).evicted).toBe(1);
|
||||
expect(counter.sweep(T0 + 3).evicted).toBe(0);
|
||||
});
|
||||
|
||||
it('잠금 중인 구간(한도에 닿은 것)은 밀어내지 않는다 — 그 다음의 잠기지 않은 구간을 밀어낸다', () => {
|
||||
const counter = new FixedWindowCounter(2, WINDOW, 3);
|
||||
|
||||
// locked 는 한도(2)에 닿아 잠겨 있다
|
||||
counter.reserve('locked', T0);
|
||||
counter.reserve('locked', T0);
|
||||
counter.reserve('open', T0 + 1);
|
||||
counter.reserve('other', T0 + 2);
|
||||
|
||||
expect(counter.reserve('new', T0 + 3).ok).toBe(true);
|
||||
expect(counter.isExhausted('locked', T0 + 3)).toBe(true);
|
||||
expect(counter.count('open', T0 + 3)).toBe(0);
|
||||
});
|
||||
|
||||
it('밀어낼 것이 없으면(모두 잠금 중) 새 키를 capacity 로 거절한다 — fail-closed, 이미 아는 키는 그대로 센다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 2);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.reserve('b', T0);
|
||||
|
||||
expect(counter.reserve('new', T0 + 1)).toEqual({
|
||||
ok: false,
|
||||
reason: 'capacity',
|
||||
});
|
||||
expect(counter.isExhausted('a', T0 + 1)).toBe(true);
|
||||
expect(counter.reserve('a', T0 + 1)).toEqual({
|
||||
ok: false,
|
||||
reason: 'limit',
|
||||
});
|
||||
expect(counter.sweep(T0 + 1)).toMatchObject({ rejected: 1 });
|
||||
});
|
||||
|
||||
it('잠긴 구간도 끝나면 다시 자리가 난다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 1);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
expect(counter.reserve('new', T0 + 1).ok).toBe(false);
|
||||
expect(counter.reserve('new', T0 + WINDOW).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('끝난 구간은 밀어내도 경고 대상(evicted)으로 세지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 1);
|
||||
|
||||
counter.reserve('k1', T0);
|
||||
counter.reserve('k2', T0 + WINDOW);
|
||||
|
||||
expect(counter.sweep(T0 + WINDOW).evicted).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/** 차지한 자리 — 돌려줄 때 같은 구간인지 확인하는 데 쓴다 */
|
||||
export interface WindowSlot {
|
||||
readonly key: string;
|
||||
/** 자리를 차지한 구간의 끝 (ms) */
|
||||
readonly windowEndsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자리 차지 결과.
|
||||
* - `limit`: 그 키가 이번 구간의 한도에 닿았다
|
||||
* - `capacity`: 기억하는 키가 상한에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 구간)이 없다 — 새 키를 받지 않는다(fail-closed)
|
||||
*/
|
||||
export type ReserveResult =
|
||||
| { readonly ok: true; readonly slot: WindowSlot }
|
||||
| { readonly ok: false; readonly reason: 'limit' | 'capacity' };
|
||||
|
||||
interface WindowState {
|
||||
count: number;
|
||||
endsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상한에서 밀어낼 기록을 찾을 때 앞(가장 먼저 끝나는 쪽)에서부터 볼 최대 개수.
|
||||
* 잠긴 기록이 앞을 채우고 있어도 요청마다 전체를 훑지 않게 한다 (그 안에 없으면 새 키를 거절한다).
|
||||
*/
|
||||
const MAX_EVICTION_SCAN = 256;
|
||||
|
||||
/**
|
||||
* 고정 구간 카운터 (프로세스 메모리) — 로그인 실패 한도의 저장소.
|
||||
*
|
||||
* **확인과 증가가 한 동기 구간에서 끝난다**(await 없음). 그래서 동시에 들어온 요청들이
|
||||
* "아직 한 자리 남았다"를 함께 보고 모두 통과하는 일이 없다 — Node 는 한 스레드라 이 구간에 끼어들 수 없다.
|
||||
*
|
||||
* 기억하는 키 개수에 상한이 있다(많은 IP·주소로 메모리를 채우는 공격 대비). 상한에 닿으면
|
||||
* - 끝난 구간과 **잠기지 않은**(한도에 닿지 않은) 구간만 밀어낸다 — 잠금 중인 구간을 밀어내면 그 공격이 풀린다
|
||||
* - 밀어낼 것이 없으면 새 키를 받지 않는다(fail-closed) — 이미 기억하는 키는 그대로 센다
|
||||
*
|
||||
* ⚠️ 인스턴스마다 따로 센다. 백엔드를 여러 대로 늘리면 한도가 인스턴스 수만큼 늘어나므로,
|
||||
* 그때는 Redis 의 원자 연산(INCR + PEXPIRE 를 한 Lua 스크립트로)으로 옮겨야 한다 (.claude/skills/nestjs/SKILL.md).
|
||||
*
|
||||
* 구간은 첫 기록부터 `windowMs` 동안이다 (기록할 때마다 늘어나지 않는다).
|
||||
*/
|
||||
export class FixedWindowCounter {
|
||||
private readonly windows = new Map<string, WindowState>();
|
||||
private evictedSinceSweep = 0;
|
||||
private rejectedSinceSweep = 0;
|
||||
|
||||
constructor(
|
||||
readonly limit: number,
|
||||
readonly windowMs: number,
|
||||
/** 기억하는 키 개수 상한 — 많은 IP 로 무작위 주소를 뿌려 메모리를 채우는 공격 대비 */
|
||||
private readonly maxKeys: number,
|
||||
) {}
|
||||
|
||||
/** 지금 구간에 센 수 (구간이 끝났으면 0) */
|
||||
count(key: string, now: number = Date.now()): number {
|
||||
return this.current(key, now)?.count ?? 0;
|
||||
}
|
||||
|
||||
isExhausted(key: string, now: number = Date.now()): boolean {
|
||||
return this.count(key, now) >= this.limit;
|
||||
}
|
||||
|
||||
/** 자리 하나를 차지한다 — 확인과 증가가 한 번에 일어난다 */
|
||||
reserve(key: string, now: number = Date.now()): ReserveResult {
|
||||
const state = this.current(key, now);
|
||||
if (state) {
|
||||
if (state.count >= this.limit) return { ok: false, reason: 'limit' };
|
||||
state.count += 1;
|
||||
return { ok: true, slot: { key, windowEndsAt: state.endsAt } };
|
||||
}
|
||||
|
||||
if (!this.makeRoom(now)) {
|
||||
this.rejectedSinceSweep += 1;
|
||||
return { ok: false, reason: 'capacity' };
|
||||
}
|
||||
const endsAt = now + this.windowMs;
|
||||
this.windows.set(key, { count: 1, endsAt });
|
||||
return { ok: true, slot: { key, windowEndsAt: endsAt } };
|
||||
}
|
||||
|
||||
/** 차지한 자리를 돌려준다 — 그 구간이 아직 이어질 때만 (구간이 바뀌었으면 이미 0 부터 다시 센다) */
|
||||
release(slot: WindowSlot, now: number = Date.now()): void {
|
||||
const state = this.current(slot.key, now);
|
||||
if (!state || state.endsAt !== slot.windowEndsAt) return;
|
||||
|
||||
state.count -= 1;
|
||||
if (state.count <= 0) this.windows.delete(slot.key);
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 구간을 지운다. 지난 정리 이후 상한 때문에 밀려난 키 수(evicted)와
|
||||
* 상한이 차서 거절한 새 키 수(rejected)도 함께 돌려준다 (로그용)
|
||||
*/
|
||||
sweep(now: number = Date.now()): {
|
||||
removed: number;
|
||||
evicted: number;
|
||||
rejected: number;
|
||||
} {
|
||||
const removed = this.removeEnded(now);
|
||||
const result = {
|
||||
removed,
|
||||
evicted: this.evictedSinceSweep,
|
||||
rejected: this.rejectedSinceSweep,
|
||||
};
|
||||
this.evictedSinceSweep = 0;
|
||||
this.rejectedSinceSweep = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.windows.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 길이가 모두 같으므로 Map 의 삽입 순서 = 구간이 끝나는 순서다.
|
||||
* 앞에서부터 끝난 것만 지우고, 아직 이어지는 구간을 만나면 멈춘다.
|
||||
*/
|
||||
private removeEnded(now: number): number {
|
||||
let removed = 0;
|
||||
for (const [key, state] of this.windows) {
|
||||
if (state.endsAt > now) break;
|
||||
this.windows.delete(key);
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private current(key: string, now: number): WindowState | undefined {
|
||||
const state = this.windows.get(key);
|
||||
if (state && state.endsAt <= now) {
|
||||
this.windows.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 키를 넣을 자리를 만든다 → 만들었으면 true.
|
||||
* 가장 먼저 끝나는 쪽(삽입 순서)부터 최대 MAX_EVICTION_SCAN 개를 보며, 끝났거나 잠기지 않은 구간 하나를 밀어낸다.
|
||||
* 잠금 중인 구간(한도에 닿은 것)은 건너뛴다.
|
||||
*/
|
||||
private makeRoom(now: number): boolean {
|
||||
if (this.windows.size < this.maxKeys) return true;
|
||||
|
||||
let scanned = 0;
|
||||
for (const [key, state] of this.windows) {
|
||||
if (scanned >= MAX_EVICTION_SCAN) break;
|
||||
scanned += 1;
|
||||
|
||||
const ended = state.endsAt <= now;
|
||||
if (ended || state.count < this.limit) {
|
||||
this.windows.delete(key);
|
||||
if (!ended) this.evictedSinceSweep += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
const strategy = new JwtStrategy({
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
});
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
|
||||
expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
}),
|
||||
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
|
||||
});
|
||||
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
|
||||
expect(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
).toThrow(BusinessException);
|
||||
|
||||
try {
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' });
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).code).toBe('AUTH_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
|
||||
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
|
||||
function accessTokenFromCookie(request: Request): string | null {
|
||||
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
|
||||
secretOrKey: settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
ignoreExpiration: false,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AuthTokenPayload): AuthenticatedUser {
|
||||
if (
|
||||
payload.typ !== 'access' ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
typeof payload.name !== 'string'
|
||||
) {
|
||||
// 리프레시 토큰을 액세스 쿠키에 넣은 경우 등
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'not-an-access-token' },
|
||||
});
|
||||
}
|
||||
return { email: payload.sub, name: payload.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import { FixedWindowCounter } from './fixed-window-counter';
|
||||
import {
|
||||
LoginAttemptService,
|
||||
type LoginAttemptSource,
|
||||
} from './login-attempt.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const IP = '203.0.113.10';
|
||||
|
||||
function reasonOf(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) {
|
||||
return { code: error.code, ...error.context };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
describe('LoginAttemptService', () => {
|
||||
let service: LoginAttemptService;
|
||||
let now: number;
|
||||
|
||||
/** 시도 하나를 시작하자마자 결과를 정한다 */
|
||||
const attempt = (
|
||||
source: LoginAttemptSource,
|
||||
outcome: 'fail' | 'succeed' | 'abort',
|
||||
) => service.begin(source)[outcome]();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new LoginAttemptService();
|
||||
now = 1_700_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('같은 주소로 실패 10회가 쌓이면 11번째 시도는 REQ_001 — IP 를 바꿔도 마찬가지', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual({
|
||||
code: 'REQ_001',
|
||||
reason: 'login-failures-exceeded',
|
||||
scope: 'address',
|
||||
email: 'a***@arasystem.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 IP 에서 실패 20회가 쌓이면 처음 보는 주소도 REQ_001', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new.person@arasystem.test', ip: IP }),
|
||||
),
|
||||
).toMatchObject({ code: 'REQ_001', scope: 'ip' });
|
||||
// 다른 IP 는 영향이 없다
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new.person@arasystem.test', ip: '192.0.2.1' }),
|
||||
),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('성공은 세지 않는다 — 같은 IP 에서 한도보다 많이 성공해도 막히지 않는다 (사무실 NAT)', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 3; i += 1) {
|
||||
attempt({ email: `staff${i}@arasystem.test`, ip: IP }, 'succeed');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('성공하면 그 주소의 실패 기록을 지운다 — IP 기록은 남긴다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
|
||||
attempt({ email: ARA, ip: IP }, 'fail');
|
||||
}
|
||||
attempt({ email: ARA, ip: IP }, 'succeed');
|
||||
|
||||
// 주소: 다시 10번 실패할 수 있다
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.9' })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
|
||||
// IP: 앞의 실패 9회는 지워지지 않았다 → 11번 더 실패하면 한도(20)
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 9; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'x@arasystem.test', ip: IP })),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
|
||||
it('판단하지 못한 시도(엔진 장애)는 두 자리를 모두 돌려준다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 3; i += 1) {
|
||||
attempt({ email: ARA, ip: IP }, 'abort');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 끝나면 다시 받는다', () => {
|
||||
const first = service.begin({ email: ARA, ip: IP });
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.1' })),
|
||||
).toMatchObject({ code: 'REQ_001', reason: 'login-in-progress' });
|
||||
// 다른 주소는 같은 IP 라도 동시에 진행할 수 있다
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
|
||||
first.fail();
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('결과는 한 번만 반영된다 — 실패 뒤 abort 가 불려도 실패가 지워지지 않는다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
const ticket = service.begin({ email: ARA, ip: IP });
|
||||
ticket.fail();
|
||||
ticket.abort();
|
||||
ticket.succeed();
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toMatchObject(
|
||||
{ scope: 'address' },
|
||||
);
|
||||
});
|
||||
|
||||
it('구간이 지나면 다시 시도할 수 있다 (IP 1분 · 주소 15분)', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
now += LOGIN_IP_FAILURE_WINDOW_MS;
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'later@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
now += LOGIN_ADDRESS_FAILURE_WINDOW_MS - 1;
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.2' })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
now += 1;
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.3' }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('주소 한도로 거부되면 IP 자리도 돌려준다 — 막힌 주소를 두드려 IP 한도를 대신 소모시키지 못한다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: IP })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('정리 작업은 상한 때문에 일찍 지운 기록이 있을 때만 경고한다', () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
service.sweep(now);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => {
|
||||
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt(
|
||||
{ email: `guess${i}@arasystem.test`, ip: `2001:db8:7:7::${i + 1}` },
|
||||
'fail',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({
|
||||
email: 'new@arasystem.test',
|
||||
ip: '2001:db8:7:7:abcd::1',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ scope: 'ip', ip: '2001:db8:7:7::/64' });
|
||||
// 다른 /64 는 영향이 없다
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new@arasystem.test', ip: '2001:db8:7:8::1' }),
|
||||
),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('IPv4-mapped 주소와 IPv4 주소는 같은 IP 로 센다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT / 2; i += 1) {
|
||||
attempt({ email: `a${i}@arasystem.test`, ip: '203.0.113.7' }, 'fail');
|
||||
attempt(
|
||||
{ email: `b${i}@arasystem.test`, ip: '::ffff:203.0.113.7' },
|
||||
'fail',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'c@arasystem.test', ip: '203.0.113.7' }),
|
||||
),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('사내 도메인이 아닌 주소 (email: null)', () => {
|
||||
it('주소 카운터에 넣지 않고 IP 만 센다 — 진행 중 잠금도 없다', () => {
|
||||
// 같은 IP 에서 여러 번 실패해도 주소 한도는 걸리지 않고, IP 한도만 쌓인다
|
||||
const tickets = Array.from({ length: 3 }, () =>
|
||||
service.begin({ email: null, ip: IP }),
|
||||
);
|
||||
tickets.forEach((ticket) => ticket.fail());
|
||||
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 3; i += 1) {
|
||||
attempt({ email: null, ip: IP }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: null, ip: IP })),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
|
||||
it('사내 주소의 주소 한도와 섞이지 않는다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: null, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.5' })),
|
||||
).toBe('allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('기억하는 키 상한 (fail-closed)', () => {
|
||||
/** 상한을 작게 바꾼 서비스 — 내부 카운터를 같은 규칙의 작은 카운터로 바꿔 끼운다 */
|
||||
function withCapacity(maxKeys: number): LoginAttemptService {
|
||||
const small = new LoginAttemptService();
|
||||
Reflect.set(
|
||||
small,
|
||||
'ipFailures',
|
||||
new FixedWindowCounter(
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
maxKeys,
|
||||
),
|
||||
);
|
||||
return small;
|
||||
}
|
||||
|
||||
it('상한이 차고 모두 잠금 중이면 새 IP 는 REQ_001(login-tracking-full) 이고, 처음 한 번 error 로그를 남긴다', () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const small = withCapacity(1);
|
||||
// 한 IP 를 한도까지 채워 잠근다 (밀어낼 수 없는 기록)
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
small.begin({ email: null, ip: '198.51.100.1' }).fail();
|
||||
}
|
||||
|
||||
for (const ip of ['198.51.100.2', '198.51.100.3']) {
|
||||
try {
|
||||
small.begin({ email: null, ip });
|
||||
fail('거절해야 한다');
|
||||
} catch (rejection) {
|
||||
expect((rejection as BusinessException).code).toBe('REQ_001');
|
||||
expect((rejection as BusinessException).context).toMatchObject({
|
||||
reason: 'login-tracking-full',
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error.mock.calls[0][0]).toMatch(/\[REQ_001\].*fail-closed/);
|
||||
|
||||
// 정리 주기에 거절 건수를 error 로 합산하고, 다음 주기에는 다시 처음 한 번 알린다
|
||||
small.sweep(now);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
expect(error.mock.calls[1][0]).toMatch(/2건을 거절했습니다/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { clientIpKey } from '@/shared/utils/client-ip';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import {
|
||||
FixedWindowCounter,
|
||||
type ReserveResult,
|
||||
type WindowSlot,
|
||||
} from './fixed-window-counter';
|
||||
|
||||
/** 끝난 구간을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 기억하는 IP·주소 개수 상한 (카운터마다). 키 하나는 수백 바이트라 상한에서도 수십 MB 안쪽이다.
|
||||
* 넘치면 끝났거나 잠기지 않은 구간만 밀어내고, 밀어낼 것이 없으면 새 키를 거절한다 (fixed-window-counter.ts).
|
||||
*/
|
||||
const MAX_TRACKED_KEYS = 100_000;
|
||||
|
||||
type Scope = 'ip' | 'address';
|
||||
type RejectReason =
|
||||
| 'login-failures-exceeded'
|
||||
| 'login-in-progress'
|
||||
| 'login-tracking-full';
|
||||
|
||||
/** 로그인 시도 하나를 세는 기준 */
|
||||
export interface LoginAttemptSource {
|
||||
/**
|
||||
* 정규화한 로그인 주소 — 사내 도메인 주소만 넣는다(없는 주소도 그대로 센다).
|
||||
* 사내 도메인이 아닌 주소는 null: 엔진에 묻지 않고 바로 거절하므로 IP 만 센다.
|
||||
* (도메인 규칙은 공개 정보라 이 차이로 알 수 있는 것은 없다)
|
||||
*/
|
||||
readonly email: string | null;
|
||||
/** 클라이언트 IP — 믿을 수 있는 프록시 뒤에서 읽은 값 (app.setup.ts 의 TRUST_PROXY). IPv6 는 /64 로 묶어 센다 */
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시).
|
||||
*/
|
||||
export interface LoginAttemptTicket {
|
||||
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */
|
||||
fail(): void;
|
||||
/** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */
|
||||
succeed(): void;
|
||||
/** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 **실패** 횟수 제한 (계약 1절).
|
||||
*
|
||||
* - 같은 IP 에서 1분에 실패 20회 / 같은 주소로 15분에 실패 10회를 넘으면 REQ_001.
|
||||
* 한도를 넘은 동안은 비밀번호가 맞아도 막는다 (무차별 대입이 계속 정답을 시험하지 못하게)
|
||||
* - IP 는 `clientIpKey()` 로 묶는다 — IPv6 는 /64, IPv4-mapped 는 IPv4 (요청 수 한도와 같은 키)
|
||||
* - **메일 엔진에 묻기 전에** 실패 한 번을 IP·주소 양쪽에 미리 차지한다(`begin`).
|
||||
* 확인과 차지가 한 동기 구간이라, 동시에 보낸 요청 100건이 "아직 한 번 남았다"를 함께 보고
|
||||
* 모두 엔진까지 가는 일이 없다. 결과가 성공이거나 판단할 수 없으면 차지한 자리를 돌려준다.
|
||||
* - 같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 한 주소는 한 번에 하나만 엔진에 묻는다.
|
||||
* - 성공하면 그 **주소**의 기록만 지운다. IP 기록은 지우지 않는다
|
||||
* (계정 하나를 아는 공격자가 성공 로그인으로 IP 카운터를 비워 가며 다른 계정을 찔러 보지 못하게)
|
||||
* - 기억하는 키가 상한에 닿고 밀어낼 기록도 없으면 새 IP·주소는 REQ_001 (fail-closed) + error 로그
|
||||
*
|
||||
* 저장소는 프로세스 메모리다. ⚠️ 여러 인스턴스로 늘리면 인스턴스마다 따로 세므로
|
||||
* Redis 원자 연산으로 옮겨야 한다 (fixed-window-counter.ts).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginAttemptService {
|
||||
private readonly logger = new Logger(LoginAttemptService.name);
|
||||
|
||||
private readonly ipFailures = new FixedWindowCounter(
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
private readonly addressFailures = new FixedWindowCounter(
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
/** 엔진 확인이 진행 중인 주소 */
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */
|
||||
private capacityAlarmRaised = false;
|
||||
|
||||
/**
|
||||
* 시도를 시작한다 — 한도를 넘었거나 같은 주소의 시도가 진행 중이면 REQ_001.
|
||||
* 이 메서드 안에는 await 가 없다 (확인과 차지 사이에 다른 요청이 끼어들지 못한다).
|
||||
*/
|
||||
begin(source: LoginAttemptSource): LoginAttemptTicket {
|
||||
const now = Date.now();
|
||||
const ipKey = digest(clientIpKey(source.ip));
|
||||
const addressKey = source.email === null ? null : digest(source.email);
|
||||
|
||||
if (addressKey !== null && this.inFlight.has(addressKey)) {
|
||||
throw this.rejected(source, 'address', 'login-in-progress');
|
||||
}
|
||||
|
||||
const ip = this.ipFailures.reserve(ipKey, now);
|
||||
if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip));
|
||||
|
||||
let addressSlot: WindowSlot | null = null;
|
||||
if (addressKey !== null) {
|
||||
const address = this.addressFailures.reserve(addressKey, now);
|
||||
if (!address.ok) {
|
||||
this.ipFailures.release(ip.slot, now);
|
||||
throw this.rejected(source, 'address', this.reasonOf(address));
|
||||
}
|
||||
addressSlot = address.slot;
|
||||
this.inFlight.add(addressKey);
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (finish: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (addressKey !== null) this.inFlight.delete(addressKey);
|
||||
finish();
|
||||
};
|
||||
|
||||
return {
|
||||
fail: () => settle(() => undefined),
|
||||
succeed: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressKey !== null) this.addressFailures.clear(addressKey);
|
||||
}),
|
||||
abort: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressSlot !== null) this.addressFailures.release(addressSlot);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 구간을 지운다. 상한 때문에 밀려난 기록이 있었다면 warn(대량 시도 징후),
|
||||
* 상한이 차서 새 키를 거절했다면 error (정상 사용자도 막히고 있다 — 즉시 확인이 필요하다)
|
||||
*/
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
const ip = this.ipFailures.sweep(now);
|
||||
const address = this.addressFailures.sweep(now);
|
||||
this.capacityAlarmRaised = false;
|
||||
|
||||
const evicted = ip.evicted + address.evicted;
|
||||
if (evicted > 0) {
|
||||
this.logger.warn(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`,
|
||||
);
|
||||
}
|
||||
const rejected = ip.rejected + address.rejected;
|
||||
if (rejected > 0) {
|
||||
this.logger.error(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private reasonOf(
|
||||
result: Extract<ReserveResult, { ok: false }>,
|
||||
): RejectReason {
|
||||
if (result.reason === 'limit') return 'login-failures-exceeded';
|
||||
|
||||
// 상한이 찼다 — 정상 사용자도 막힐 수 있는 상태라 처음 한 번은 바로 error 로 알린다 (나머지는 정리 주기에 합계로)
|
||||
if (!this.capacityAlarmRaised) {
|
||||
this.capacityAlarmRaised = true;
|
||||
this.logger.error(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 것)이 없어 새 IP·주소의 로그인을 거절합니다 (fail-closed). 대량 시도가 의심됩니다.`,
|
||||
);
|
||||
}
|
||||
return 'login-tracking-full';
|
||||
}
|
||||
|
||||
private rejected(
|
||||
source: LoginAttemptSource,
|
||||
scope: Scope,
|
||||
reason: RejectReason,
|
||||
): BusinessException {
|
||||
return new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason,
|
||||
scope,
|
||||
...(scope === 'ip'
|
||||
? { ip: clientIpKey(source.ip) }
|
||||
: { email: maskEmail(source.email ?? '') }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 키에 주소·IP 원문을 남기지 않는다 (Redis 로 옮겨도 목록이 드러나지 않게) */
|
||||
function digest(subject: string): string {
|
||||
return createHash('sha256').update(subject).digest('base64url');
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ERROR_CODES } from '@/common/errors/error-codes';
|
||||
import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
allowedAttachmentExtensions,
|
||||
isAllowedAttachment,
|
||||
normalizeMimeType,
|
||||
resolveAttachmentType,
|
||||
resolveAttachmentsTotalLimit,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('첨부 개수 한도', () => {
|
||||
it('레지스트리의 MAIL_006 문구가 말하는 개수는 한도 상수와 같다 — 문구와 실제 거절 기준이 어긋나지 않게', () => {
|
||||
expect(ERROR_CODES.MAIL_006.message).toContain(
|
||||
`${MAX_ATTACHMENTS_PER_MESSAGE}개`,
|
||||
);
|
||||
});
|
||||
|
||||
it('형식 검증(DTO)의 목록 상한은 개수 한도보다 넉넉하다 — 한도를 넘긴 요청이 VAL_001 이 아니라 MAIL_006 을 받게', () => {
|
||||
expect(MAX_ATTACHMENT_LIST_LENGTH).toBeGreaterThan(
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('첨부 합계 한도 (resolveAttachmentsTotalLimit)', () => {
|
||||
it('설정 한도와 엔진 한도 중 작은 값을 쓴다', () => {
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, 50_000_000)).toBe(25 * MB);
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, 10 * MB)).toBe(10 * MB);
|
||||
});
|
||||
|
||||
it('엔진이 한도를 알려 주지 않으면 설정 한도만 쓴다', () => {
|
||||
for (const unknown of [null, undefined, 0, -1, 1.5, Number.NaN]) {
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, unknown)).toBe(25 * MB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 첨부 형식 정책', () => {
|
||||
it('계약의 허용 형식을 모두 받는다 (확장자 + MIME)', () => {
|
||||
const allowed: Array<[string, string]> = [
|
||||
['보고서.pdf', 'application/pdf'],
|
||||
['a.doc', 'application/msword'],
|
||||
[
|
||||
'a.docx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
['a.xls', 'application/vnd.ms-excel'],
|
||||
[
|
||||
'체크리스트.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
['a.ppt', 'application/vnd.ms-powerpoint'],
|
||||
[
|
||||
'a.pptx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
['기안.hwp', 'application/x-hwp'],
|
||||
['기안.hwpx', 'application/hwp+zip'],
|
||||
['메모.txt', 'text/plain'],
|
||||
['목록.csv', 'text/csv'],
|
||||
['사진.png', 'image/png'],
|
||||
['사진.jpg', 'image/jpeg'],
|
||||
['사진.JPEG', 'image/jpeg'],
|
||||
['움짤.gif', 'image/gif'],
|
||||
['사진.webp', 'image/webp'],
|
||||
['묶음.zip', 'application/zip'],
|
||||
];
|
||||
|
||||
for (const [name, mime] of allowed) {
|
||||
expect(isAllowedAttachment(name, mime)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('브라우저·OS 가 보내는 다른 MIME 표기도 받는다', () => {
|
||||
expect(
|
||||
isAllowedAttachment('묶음.zip', 'application/x-zip-compressed'),
|
||||
).toBe(true);
|
||||
expect(isAllowedAttachment('목록.csv', 'application/vnd.ms-excel')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAllowedAttachment('기안.hwp', 'application/haansofthwp')).toBe(
|
||||
true,
|
||||
);
|
||||
// 한컴오피스가 없는 PC 는 octet-stream 으로 보낸다
|
||||
expect(isAllowedAttachment('기안.hwp', 'application/octet-stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAllowedAttachment('메모.txt', 'text/plain; charset=utf-8')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('브라우저가 그릴 수 있는 형식은 octet-stream 으로 위장하면 거부한다', () => {
|
||||
for (const name of ['사진.png', '보고서.pdf', '메모.txt', '사진.jpg']) {
|
||||
expect(isAllowedAttachment(name, 'application/octet-stream')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('확장자와 MIME 이 어긋나면 거부한다 (위장 방지)', () => {
|
||||
expect(isAllowedAttachment('payload.png', 'text/html')).toBe(false);
|
||||
expect(isAllowedAttachment('payload.html', 'image/png')).toBe(false);
|
||||
expect(isAllowedAttachment('noext', 'application/pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('실행·렌더링 가능한 형식은 허용 목록에 없다', () => {
|
||||
const extensions = allowedAttachmentExtensions();
|
||||
|
||||
for (const dangerous of [
|
||||
'.html',
|
||||
'.htm',
|
||||
'.svg',
|
||||
'.js',
|
||||
'.exe',
|
||||
'.sh',
|
||||
'.bat',
|
||||
]) {
|
||||
expect(extensions).not.toContain(dangerous);
|
||||
}
|
||||
expect(isAllowedAttachment('icon.svg', 'image/svg+xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('발송 형식은 확장자로 정한다 — 허용 목록 밖이면 null', () => {
|
||||
expect(resolveAttachmentType('체크리스트.XLSX')).toBe(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(resolveAttachmentType('사진.jpeg')).toBe('image/jpeg');
|
||||
expect(resolveAttachmentType('virus.exe')).toBeNull();
|
||||
expect(resolveAttachmentType('README')).toBeNull();
|
||||
});
|
||||
|
||||
it('MIME 을 소문자·매개변수 없이 맞춘다', () => {
|
||||
expect(normalizeMimeType(' Text/Plain; charset=UTF-8 ')).toBe('text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
|
||||
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
|
||||
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
|
||||
});
|
||||
|
||||
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
|
||||
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
|
||||
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
|
||||
});
|
||||
|
||||
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
|
||||
const long = `${'가'.repeat(300)}.xlsx`;
|
||||
const result = sanitizeFileName(long);
|
||||
|
||||
expect(Array.from(result)).toHaveLength(255);
|
||||
expect(result.endsWith('.xlsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('남는 것이 없으면 기본 이름을 쓴다', () => {
|
||||
expect(sanitizeFileName('')).toBe('첨부파일');
|
||||
expect(sanitizeFileName('dir/')).toBe('첨부파일');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { extname } from 'path';
|
||||
|
||||
import { MAX_FILE_NAME_LENGTH } from './mail.constants';
|
||||
|
||||
/**
|
||||
* 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다.
|
||||
*
|
||||
* - **허용 목록**만 받는다. 확장자와 MIME 을 **둘 다** 검사한다 (.claude/rules/security.md "파일 업로드")
|
||||
* - 저장·발송에 쓰는 형식은 클라이언트가 보낸 MIME 이 아니라 **확장자로 정한 정식 형식**이다
|
||||
* - 브라우저가 실행할 수 있는 형식(.html·.svg·.js 등)은 넣지 않는다
|
||||
*/
|
||||
|
||||
const OCTET_STREAM = 'application/octet-stream';
|
||||
|
||||
/**
|
||||
* 메일 한 통에 붙일 수 있는 첨부 개수 — 넘으면 400 MAIL_006 (계약 2절. 화면은 첨부를 추가할 때 미리 막는다).
|
||||
* 업로드한 파일과 전달하는 원본 첨부를 함께 센다. **개수 한도는 이 상수 하나**다 —
|
||||
* 발송 검사(mail.service.ts)·API 문서가 이 값을 쓰고, 레지스트리의 MAIL_006 문구는 테스트가 이 값과 맞춰 본다.
|
||||
*/
|
||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
|
||||
|
||||
/**
|
||||
* 발송 본문 검증(DTO)이 받는 첨부 목록 길이 상한 — 한도가 아니라 **남용 방지용**이다 (넘으면 400 VAL_001).
|
||||
*
|
||||
* 100 으로 둔 근거:
|
||||
* - 한도(20)보다 넉넉해야 한다. 파일을 한꺼번에 많이 고른 경우(21~100개)는 검증을 통과시켜 MAIL_006 으로 답한다 —
|
||||
* VAL_001 문구("입력하신 정보를 다시 확인해 주세요")로는 무엇을 고칠지 알 수 없다.
|
||||
* - 끝은 있어야 한다. 화면으로는 만들 수 없는 길이는 형식 남용으로 보고 서비스·메일 엔진까지 보내지 않는다.
|
||||
* (본문 해석·검증 비용 자체는 JSON 본문 한도 1MB 가 묶는다 — app.setup.ts 의 JSON_BODY_LIMIT)
|
||||
* - 같은 DTO 의 다른 목록(받는 사람·참조·숨은 참조) 상한도 100 이다 (mail.constants.ts 의 MAX_RECIPIENTS).
|
||||
*/
|
||||
export const MAX_ATTACHMENT_LIST_LENGTH = 100;
|
||||
|
||||
interface AttachmentTypeRule {
|
||||
/** 서버가 저장·발송에 쓰는 정식 형식 */
|
||||
readonly type: string;
|
||||
/** 업로드 때 받아들이는 MIME */
|
||||
readonly accept: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장자 → 형식.
|
||||
*
|
||||
* 문서·압축 형식(브라우저가 직접 그리지 않는 형식)은 `application/octet-stream` 도 받는다.
|
||||
* 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다.
|
||||
* 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다.
|
||||
* 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다.
|
||||
*/
|
||||
const ATTACHMENT_TYPES: ReadonlyMap<string, AttachmentTypeRule> = new Map([
|
||||
['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }],
|
||||
[
|
||||
'.doc',
|
||||
{
|
||||
type: 'application/msword',
|
||||
accept: ['application/msword', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.docx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.xls',
|
||||
{
|
||||
type: 'application/vnd.ms-excel',
|
||||
accept: ['application/vnd.ms-excel', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.xlsx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.ppt',
|
||||
{
|
||||
type: 'application/vnd.ms-powerpoint',
|
||||
accept: ['application/vnd.ms-powerpoint', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.pptx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.hwp',
|
||||
{
|
||||
type: 'application/x-hwp',
|
||||
accept: [
|
||||
'application/x-hwp',
|
||||
'application/haansofthwp',
|
||||
'application/vnd.hancom.hwp',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.hwpx',
|
||||
{
|
||||
type: 'application/hwp+zip',
|
||||
accept: [
|
||||
'application/hwp+zip',
|
||||
'application/haansofthwpx',
|
||||
'application/vnd.hancom.hwpx',
|
||||
'application/x-hwpx',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
['.txt', { type: 'text/plain', accept: ['text/plain'] }],
|
||||
[
|
||||
'.csv',
|
||||
{
|
||||
type: 'text/csv',
|
||||
// 엑셀이 설치된 Windows 는 .csv 를 application/vnd.ms-excel 로 보낸다
|
||||
accept: [
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
'text/x-csv',
|
||||
'application/vnd.ms-excel',
|
||||
'text/plain',
|
||||
],
|
||||
},
|
||||
],
|
||||
['.png', { type: 'image/png', accept: ['image/png'] }],
|
||||
['.jpg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
|
||||
['.jpeg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
|
||||
['.gif', { type: 'image/gif', accept: ['image/gif'] }],
|
||||
['.webp', { type: 'image/webp', accept: ['image/webp'] }],
|
||||
[
|
||||
'.zip',
|
||||
{
|
||||
type: 'application/zip',
|
||||
// Windows 의 Chrome 은 application/x-zip-compressed 로 보낸다
|
||||
accept: [
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-zip',
|
||||
'multipart/x-zip',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/** 정리 후 이름이 비면 쓰는 이름 */
|
||||
const FALLBACK_FILE_NAME = '첨부파일';
|
||||
|
||||
/** 'Text/Plain; charset=UTF-8' → 'text/plain' */
|
||||
export function normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
return extname(fileName).toLowerCase();
|
||||
}
|
||||
|
||||
/** 업로드 허용 여부 — 확장자가 허용 목록에 있고, MIME 이 그 확장자에 맞아야 통과한다 */
|
||||
export function isAllowedAttachment(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
): boolean {
|
||||
const rule = ATTACHMENT_TYPES.get(extensionOf(fileName));
|
||||
return (
|
||||
rule !== undefined && rule.accept.includes(normalizeMimeType(mimeType))
|
||||
);
|
||||
}
|
||||
|
||||
/** 파일명 확장자로 정식 형식을 정한다. 허용 목록에 없으면 null */
|
||||
export function resolveAttachmentType(fileName: string): string | null {
|
||||
return ATTACHMENT_TYPES.get(extensionOf(fileName))?.type ?? null;
|
||||
}
|
||||
|
||||
/** 안내·문서용 허용 확장자 목록 */
|
||||
export function allowedAttachmentExtensions(): string[] {
|
||||
return [...ATTACHMENT_TYPES.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 설정 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB)와 메일 엔진 한도 중 작은 값.
|
||||
*
|
||||
* **이 함수 하나만** 한도를 정한다. 발송 검사(어댑터의 FILE_002)와 `GET /api/mail-quota` 의
|
||||
* `attachmentTotalMaxBytes` 가 같은 값을 써야 화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않는다.
|
||||
* 엔진이 한도를 알려 주지 않으면(null·0 이하·정수가 아님) 설정 한도만 쓴다.
|
||||
*/
|
||||
export function resolveAttachmentsTotalLimit(
|
||||
configuredBytes: number,
|
||||
engineBytes: number | null | undefined,
|
||||
): number {
|
||||
const engineKnown =
|
||||
typeof engineBytes === 'number' &&
|
||||
Number.isSafeInteger(engineBytes) &&
|
||||
engineBytes > 0;
|
||||
return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 표시·헤더용으로 파일명을 정리한다.
|
||||
* - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다)
|
||||
* - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다
|
||||
* - 길이를 제한하되 확장자는 남긴다
|
||||
*/
|
||||
export function sanitizeFileName(rawName: string): string {
|
||||
const lastSegment = rawName.split(/[\\/]/).pop() ?? '';
|
||||
const cleaned = Array.from(lastSegment)
|
||||
.filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0))
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const chars = Array.from(cleaned);
|
||||
if (chars.length === 0) return FALLBACK_FILE_NAME;
|
||||
if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned;
|
||||
|
||||
const extensionChars = Array.from(extname(cleaned));
|
||||
if (extensionChars.length >= MAX_FILE_NAME_LENGTH) {
|
||||
return chars.slice(0, MAX_FILE_NAME_LENGTH).join('');
|
||||
}
|
||||
return (
|
||||
chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') +
|
||||
extensionChars.join('')
|
||||
);
|
||||
}
|
||||
|
||||
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
|
||||
function isControlOrBidiChar(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
||||
codePoint === 0x200e ||
|
||||
codePoint === 0x200f ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import type {
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailQuota,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
} from '@/modules/mail/mail.types';
|
||||
|
||||
/**
|
||||
* 메일 응답 DTO — Swagger 문서용. 실제 값은 도메인 타입(mail.types.ts)과 같은 모양이다.
|
||||
* 계약: docs/mail/api-contract.md 2절
|
||||
*/
|
||||
|
||||
const MAILBOX_ROLES: MailboxRole[] = [
|
||||
'inbox',
|
||||
'drafts',
|
||||
'sent',
|
||||
'junk',
|
||||
'trash',
|
||||
'archive',
|
||||
];
|
||||
|
||||
export class MailAddressDto implements MailAddress {
|
||||
@ApiProperty({
|
||||
description: '표시 이름 (없으면 null)',
|
||||
example: '이도윤',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 주소',
|
||||
example: 'doyun.lee@arasystem.it.kr',
|
||||
})
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export class MailAttachmentDto implements MailAttachment {
|
||||
@ApiProperty({
|
||||
description: '첨부 id — 다운로드 경로와 발송 본문(전달)에 그대로 쓴다',
|
||||
example: 'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '파일명', example: '체크리스트.xlsx' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '형식 (표시용 — 다운로드는 항상 application/octet-stream)',
|
||||
example:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
|
||||
size!: number;
|
||||
}
|
||||
|
||||
export class MailboxDto implements Mailbox {
|
||||
@ApiProperty({ description: '메일함 id', example: 'a' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '역할이 있으면 한국어 표준 이름, 없으면 서버의 이름',
|
||||
example: '받은편지함',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '역할',
|
||||
enum: MAILBOX_ROLES,
|
||||
nullable: true,
|
||||
example: 'inbox',
|
||||
})
|
||||
role!: MailboxRole | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상위 메일함 id',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: null,
|
||||
})
|
||||
parentId!: string | null;
|
||||
|
||||
@ApiProperty({ description: '서버의 정렬 값', example: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ description: '메일 수', example: 143 })
|
||||
totalCount!: number;
|
||||
|
||||
@ApiProperty({ description: '읽지 않은 메일 수', example: 3 })
|
||||
unreadCount!: number;
|
||||
}
|
||||
|
||||
export class MailboxPageDto {
|
||||
@ApiProperty({
|
||||
type: [MailboxDto],
|
||||
description:
|
||||
'받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(sortOrder, 이름 순)',
|
||||
})
|
||||
items!: MailboxDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: '한 번에 모두 내려준다 (hasNext=false)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
export class MailQuotaDto implements MailQuota {
|
||||
@ApiProperty({ description: '사용량 (바이트)', example: 966367641 })
|
||||
usedBytes!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '한도 (바이트). 한도가 없으면 null',
|
||||
nullable: true,
|
||||
type: Number,
|
||||
example: 10737418240,
|
||||
})
|
||||
limitBytes!: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'첨부 1개 최대 크기 (바이트) — 서버의 MAIL_ATTACHMENT_MAX_SIZE_MB',
|
||||
example: 20971520,
|
||||
})
|
||||
attachmentMaxBytes!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일 한 통의 첨부 합계 최대 크기 (바이트) — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 메일 엔진 한도 중 작은 값. ' +
|
||||
'발송 때 검사하는 값과 같다 (넘으면 413 FILE_002). 전달하는 원본 첨부도 세고, 같은 첨부를 두 번 붙이면 두 번 센다',
|
||||
example: 26214400,
|
||||
})
|
||||
attachmentTotalMaxBytes!: number;
|
||||
}
|
||||
|
||||
export class MessageSummaryDto implements MessageSummary {
|
||||
@ApiProperty({ description: '메일 id', example: 'eaaaaab' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '스레드 id', example: 'b' })
|
||||
threadId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '속한 메일함 id 목록',
|
||||
type: [String],
|
||||
example: ['a'],
|
||||
})
|
||||
mailboxIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '보낸 사람',
|
||||
type: MailAddressDto,
|
||||
nullable: true,
|
||||
})
|
||||
from!: MailAddressDto | null;
|
||||
|
||||
@ApiProperty({ description: '받는 사람', type: [MailAddressDto] })
|
||||
to!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '제목 (없으면 빈 문자열)',
|
||||
example: '메일 서버 이전 일정 공유',
|
||||
})
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({ description: '본문 미리보기', example: '도윤님, 다음 주…' })
|
||||
preview!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '받은 시각 (ISO 8601 UTC)',
|
||||
example: '2026-10-26T05:12:00.000Z',
|
||||
})
|
||||
receivedAt!: string;
|
||||
|
||||
@ApiProperty({ description: '읽음 여부', example: false })
|
||||
isRead!: boolean;
|
||||
|
||||
@ApiProperty({ description: '중요 표시 여부', example: false })
|
||||
isFlagged!: boolean;
|
||||
|
||||
@ApiProperty({ description: '첨부 여부', example: true })
|
||||
hasAttachment!: boolean;
|
||||
|
||||
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
|
||||
size!: number;
|
||||
}
|
||||
|
||||
export class MessageDetailDto
|
||||
extends MessageSummaryDto
|
||||
implements MessageDetail
|
||||
{
|
||||
@ApiProperty({ description: '참조', type: [MailAddressDto] })
|
||||
cc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '숨은 참조 (보낸 메일에서만 보인다)',
|
||||
type: [MailAddressDto],
|
||||
})
|
||||
bcc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({ description: '회신 주소', type: [MailAddressDto] })
|
||||
replyTo!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '보낸 시각 (ISO 8601 UTC)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-26T05:11:58.000Z',
|
||||
})
|
||||
sentAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message-ID 헤더 값 (꺾쇠 괄호 없이)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '20261026051158.abc@arasystem.it.kr',
|
||||
})
|
||||
messageId!: string | null;
|
||||
|
||||
@ApiProperty({ description: 'References 헤더', type: [String] })
|
||||
references!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
htmlBody!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '일반 텍스트 본문',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
textBody!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내',
|
||||
example: false,
|
||||
})
|
||||
hasBlockedImages!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '첨부 (본문 내장 이미지 포함)',
|
||||
type: [MailAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
}
|
||||
|
||||
export class MessagePageDto {
|
||||
@ApiProperty({
|
||||
type: [MessageSummaryDto],
|
||||
description: '받은 시각 내림차순',
|
||||
})
|
||||
items!: MessageSummaryDto[];
|
||||
|
||||
@ApiProperty({ type: PageInfoDto, description: 'total 포함' })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
export class SentMessageDto {
|
||||
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
|
||||
id!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_SEARCH_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
|
||||
/** GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 */
|
||||
export class MessageListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiProperty({ description: '메일함 id (필수)', example: 'a' })
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `검색어 (보낸 사람·받는 사람·제목·본문, ${MAX_SEARCH_LENGTH}자 이하)`,
|
||||
example: '서버 이전',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_SEARCH_LENGTH, {
|
||||
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
q?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
} from '@/modules/mail/attachment.policy';
|
||||
import { SendMessageDto } from './send-message.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
|
||||
async function constraintsOf(body: object): Promise<string[]> {
|
||||
const dto = plainToInstance(SendMessageDto, body);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
const withAttachments = (count: number) => ({
|
||||
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
|
||||
subject: '제목',
|
||||
body: '본문',
|
||||
attachments: Array.from({ length: count }, (_, i) => ({
|
||||
id: `blob-${i}`,
|
||||
name: `파일${i}.pdf`,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('SendMessageDto 첨부 목록', () => {
|
||||
it('개수 한도를 넘는 목록도 형식 검증은 통과한다 — 개수 한도(MAIL_006)는 서비스가 답한다', async () => {
|
||||
for (const count of [
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE + 1,
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
]) {
|
||||
expect(await constraintsOf(withAttachments(count))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('남용 방지 상한(100개)을 넘는 목록은 형식 검증에서 막는다 (VAL_001)', async () => {
|
||||
expect(
|
||||
await constraintsOf(withAttachments(MAX_ATTACHMENT_LIST_LENGTH + 1)),
|
||||
).toContain('arrayMaxSize');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
} from '@/modules/mail/attachment.policy';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_EMAIL_LENGTH,
|
||||
MAX_FILE_NAME_LENGTH,
|
||||
MAX_RECIPIENTS,
|
||||
MAX_SUBJECT_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
|
||||
export class RecipientDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '표시 이름',
|
||||
example: '이도윤',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '받는 사람 이름 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_DISPLAY_NAME_LENGTH, {
|
||||
message: `받는 사람 이름은 ${MAX_DISPLAY_NAME_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
name?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 주소',
|
||||
example: 'doyun.lee@arasystem.it.kr',
|
||||
})
|
||||
@IsEmail({}, { message: '받는 사람 메일 주소 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_EMAIL_LENGTH, {
|
||||
message: '받는 사람 메일 주소가 너무 깁니다.',
|
||||
})
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export class OutgoingAttachmentDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'첨부 id — 업로드 응답의 id, 또는 전달할 원본 메일의 attachments[].id (같은 계정의 파일)',
|
||||
example: 'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae',
|
||||
})
|
||||
@Matches(MAIL_ID_PATTERN, { message: '첨부파일을 다시 선택해 주세요.' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'파일명 — 확장자로 형식을 정한다 (업로드와 같은 허용 목록, 밖이면 FILE_001)',
|
||||
example: '체크리스트.xlsx',
|
||||
})
|
||||
@IsString({ message: '첨부파일 이름 형식이 올바르지 않습니다.' })
|
||||
@IsNotEmpty({ message: '첨부파일 이름이 비어 있습니다.' })
|
||||
@MaxLength(MAX_FILE_NAME_LENGTH, {
|
||||
message: `첨부파일 이름은 ${MAX_FILE_NAME_LENGTH}자 이하여야 합니다.`,
|
||||
})
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** POST /api/messages — 계약 2절 "발송 본문" */
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({
|
||||
description: '받는 사람 (1명 이상)',
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
|
||||
@ArrayMinSize(1, { message: '받는 사람을 1명 이상 입력해 주세요.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
to!: RecipientDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: '참조', type: [RecipientDto] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: '참조 형식이 올바르지 않습니다.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `참조는 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
cc?: RecipientDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `숨은 참조 (받는 사람·참조·숨은 참조 합계 ${MAX_RECIPIENTS}명 이하)`,
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: '숨은 참조 형식이 올바르지 않습니다.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `숨은 참조는 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
bcc?: RecipientDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: `제목 (${MAX_SUBJECT_LENGTH}자 이하, 빈 문자열 가능)`,
|
||||
example: 'Re: 메일 서버 이전 일정 공유',
|
||||
})
|
||||
@IsString({ message: '제목 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_SUBJECT_LENGTH, {
|
||||
message: `제목은 ${MAX_SUBJECT_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능)`,
|
||||
example: '도윤님, 확인했습니다.',
|
||||
})
|
||||
@IsString({ message: '본문 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_BODY_LENGTH, {
|
||||
message: `본문은 ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
body!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `첨부 (${MAX_ATTACHMENTS_PER_MESSAGE}개 이하, 넘으면 400 MAIL_006) — 업로드한 파일 또는 전달할 원본 첨부`,
|
||||
type: [OutgoingAttachmentDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: '첨부 형식이 올바르지 않습니다.' })
|
||||
// 남용 방지용 상한일 뿐이다 — 개수 한도(MAIL_006)는 서비스가 검사한다 (근거는 MAX_ATTACHMENT_LIST_LENGTH)
|
||||
@ArrayMaxSize(MAX_ATTACHMENT_LIST_LENGTH, {
|
||||
message: `첨부 목록은 ${MAX_ATTACHMENT_LIST_LENGTH}개를 넘을 수 없습니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OutgoingAttachmentDto)
|
||||
attachments?: OutgoingAttachmentDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'답장할 메일 id — 스레드 헤더(In-Reply-To·References)를 서버가 채운다. 전달에는 보내지 않는다',
|
||||
example: 'eaaaaab',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
|
||||
inReplyTo?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, Matches, ValidateIf } from 'class-validator';
|
||||
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** 값이 왔을 때만 검사한다 — null 은 "보내지 않음"이 아니라 형식 오류로 막는다 */
|
||||
const isPresent = (_object: object, value: unknown) => value !== undefined;
|
||||
|
||||
/** PATCH /api/messages/{id} — 하나 이상 지정한다 (빈 요청은 VAL_001) */
|
||||
export class UpdateMessageDto {
|
||||
@ApiPropertyOptional({ description: '읽음 여부', example: true })
|
||||
@ValidateIf(isPresent)
|
||||
@IsBoolean({ message: '읽음 여부는 true 또는 false 여야 합니다.' })
|
||||
isRead?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '중요 표시 여부', example: true })
|
||||
@ValidateIf(isPresent)
|
||||
@IsBoolean({ message: '중요 표시 여부는 true 또는 false 여야 합니다.' })
|
||||
isFlagged?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '옮길 메일함 id — 지정하면 이 메일함 하나에만 속하게 된다',
|
||||
example: 'b',
|
||||
})
|
||||
@ValidateIf(isPresent)
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { InlinePart } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
INLINE_IMAGES_MAX_COUNT,
|
||||
INLINE_IMAGE_MAX_BYTES,
|
||||
findReferencedContentIds,
|
||||
normalizeContentId,
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
} from './inline-images';
|
||||
|
||||
const part = (cid: string, type = 'image/png', size = 1000): InlinePart => ({
|
||||
attachmentId: `blob-${cid}`,
|
||||
cid,
|
||||
type,
|
||||
size,
|
||||
});
|
||||
|
||||
describe('본문 내장 이미지 정책', () => {
|
||||
it('Content-ID 를 꺾쇠·퍼센트 인코딩·대소문자와 무관하게 맞춘다', () => {
|
||||
expect(normalizeContentId('<Logo@AraSystem.it.kr>')).toBe(
|
||||
'logo@arasystem.it.kr',
|
||||
);
|
||||
expect(normalizeContentId('logo%40arasystem.it.kr')).toBe(
|
||||
'logo@arasystem.it.kr',
|
||||
);
|
||||
// 잘못된 퍼센트 인코딩이어도 실패하지 않는다
|
||||
expect(normalizeContentId('bad%zz')).toBe('bad%zz');
|
||||
});
|
||||
|
||||
it('본문이 참조하는 cid 만 찾는다', () => {
|
||||
const ids = findReferencedContentIds(
|
||||
`<img src="cid:a@x"><img src='CID:B@X'><div style="background:url(cid:c@x)"></div>`,
|
||||
);
|
||||
|
||||
expect([...ids].sort()).toEqual(['a@x', 'b@x', 'c@x']);
|
||||
});
|
||||
|
||||
it('형식·크기·참조 여부를 모두 만족하는 것만 고른다', () => {
|
||||
const referenced = new Set(['ok@x', 'svg@x', 'big@x', 'pdf@x']);
|
||||
|
||||
const selected = selectInlineImages(
|
||||
[
|
||||
part('ok@x'),
|
||||
part('svg@x', 'image/svg+xml'),
|
||||
part('big@x', 'image/png', INLINE_IMAGE_MAX_BYTES + 1),
|
||||
part('pdf@x', 'application/pdf'),
|
||||
part('unreferenced@x'),
|
||||
],
|
||||
referenced,
|
||||
);
|
||||
|
||||
expect(selected.map((p) => p.cid)).toEqual(['ok@x']);
|
||||
});
|
||||
|
||||
it('합계 한도와 개수 상한을 지킨다', () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) =>
|
||||
part(`${i}@x`, 'image/gif', 100),
|
||||
);
|
||||
const referenced = new Set(many.map((p) => p.cid));
|
||||
|
||||
expect(selectInlineImages(many, referenced)).toHaveLength(
|
||||
INLINE_IMAGES_MAX_COUNT,
|
||||
);
|
||||
|
||||
const heavy = Array.from({ length: 10 }, (_, i) =>
|
||||
part(`${i}@x`, 'image/jpeg', INLINE_IMAGE_MAX_BYTES),
|
||||
);
|
||||
// 300KB × 6 = 1.8MB 까지만 들어간다 (합계 2MB)
|
||||
expect(
|
||||
selectInlineImages(heavy, new Set(heavy.map((p) => p.cid))),
|
||||
).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('data: URI 는 허용 목록의 정식 형식으로만 만든다', () => {
|
||||
expect(toDataUri('IMAGE/JPG', Buffer.from([1, 2, 3]))).toBe(
|
||||
'data:image/jpeg;base64,AQID',
|
||||
);
|
||||
expect(toDataUri('image/svg+xml', Buffer.from('<svg/>'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { InlinePart } from '@/modules/mail/mail.types';
|
||||
|
||||
/**
|
||||
* 본문 내장 이미지(`<img src="cid:…">`) 정책 (순수 함수) — 계약 2절.
|
||||
*
|
||||
* 작은 이미지만 data: URI 로 바꿔 본문에 넣고, 나머지는 정화 단계에서 지운다(첨부 목록에는 남는다).
|
||||
* SVG 는 스크립트를 품을 수 있으므로 넣지 않는다.
|
||||
*/
|
||||
|
||||
/** 이미지 1개 최대 크기 */
|
||||
export const INLINE_IMAGE_MAX_BYTES = 300 * 1024;
|
||||
/** 메일 1통의 내장 이미지 합계 최대 크기 */
|
||||
export const INLINE_IMAGES_TOTAL_MAX_BYTES = 2 * 1024 * 1024;
|
||||
/** 메일 1통에서 내려받아 넣을 내장 이미지 개수 상한 */
|
||||
export const INLINE_IMAGES_MAX_COUNT = 20;
|
||||
|
||||
/** 넣을 수 있는 형식 → data: URI 에 쓰는 정식 형식 */
|
||||
const INLINE_IMAGE_TYPES: ReadonlyMap<string, string> = new Map([
|
||||
['image/png', 'image/png'],
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/jpg', 'image/jpeg'],
|
||||
['image/pjpeg', 'image/jpeg'],
|
||||
['image/gif', 'image/gif'],
|
||||
['image/webp', 'image/webp'],
|
||||
]);
|
||||
|
||||
/** 본문 HTML 의 cid 참조 — 따옴표·공백·괄호 앞까지 */
|
||||
const CID_REFERENCE = /cid:([^"'\s>)]+)/gi;
|
||||
|
||||
/**
|
||||
* Content-ID 를 비교 가능한 형태로 맞춘다.
|
||||
* HTML 의 `cid:` URL 은 퍼센트 인코딩될 수 있고(RFC 2392), 헤더 값에는 꺾쇠 괄호가 붙기도 한다.
|
||||
*/
|
||||
export function normalizeContentId(value: string): string {
|
||||
let id = value.trim();
|
||||
try {
|
||||
id = decodeURIComponent(id);
|
||||
} catch {
|
||||
// 잘못된 퍼센트 인코딩 — 원문 그대로 비교한다
|
||||
}
|
||||
return id.replace(/^</, '').replace(/>$/, '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** 본문이 실제로 참조하는 Content-ID — 참조하지 않는 부분은 내려받지 않는다 */
|
||||
export function findReferencedContentIds(html: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const match of html.matchAll(CID_REFERENCE)) {
|
||||
ids.add(normalizeContentId(match[1]));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 넣을 수 있는 이미지 형식이면 정식 형식, 아니면 null */
|
||||
export function inlineImageType(type: string): string | null {
|
||||
return INLINE_IMAGE_TYPES.get(type.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한.
|
||||
* (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다)
|
||||
*/
|
||||
export function selectInlineImages(
|
||||
parts: readonly InlinePart[],
|
||||
referenced: ReadonlySet<string>,
|
||||
): InlinePart[] {
|
||||
const selected: InlinePart[] = [];
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
const cid = normalizeContentId(part.cid);
|
||||
const eligible =
|
||||
inlineImageType(part.type) !== null &&
|
||||
part.size > 0 &&
|
||||
part.size <= INLINE_IMAGE_MAX_BYTES &&
|
||||
referenced.has(cid) &&
|
||||
!seen.has(cid) &&
|
||||
total + part.size <= INLINE_IMAGES_TOTAL_MAX_BYTES;
|
||||
|
||||
if (!eligible) continue;
|
||||
|
||||
seen.add(cid);
|
||||
total += part.size;
|
||||
selected.push(part);
|
||||
if (selected.length >= INLINE_IMAGES_MAX_COUNT) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** 이미지 바이트 → data: URI (형식은 허용 목록의 정식 형식만 쓴다) */
|
||||
export function toDataUri(type: string, data: Buffer): string | null {
|
||||
const canonical = inlineImageType(type);
|
||||
return canonical
|
||||
? `data:${canonical};base64,${data.toString('base64')}`
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
|
||||
const clean = (html: string, inline?: ReadonlyMap<string, string>) =>
|
||||
sanitizeMailHtml(html, inline).html;
|
||||
|
||||
const PNG_DATA_URI = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
describe('sanitizeMailHtml', () => {
|
||||
describe('스크립트', () => {
|
||||
it('script 태그는 내용까지 지운다', () => {
|
||||
expect(clean('<p>본문</p><script>alert(1)</script>')).toBe('<p>본문</p>');
|
||||
expect(
|
||||
clean('<SCRIPT src="https://evil.example/x.js"></SCRIPT>안녕'),
|
||||
).toBe('안녕');
|
||||
});
|
||||
|
||||
it('이벤트 핸들러 속성을 모두 지운다', () => {
|
||||
expect(
|
||||
clean('<p onclick="steal()" onmouseover="x()" style="color:red">a</p>'),
|
||||
).toBe('<p style="color:red">a</p>');
|
||||
expect(clean('<body onload="x()"><div>본문</div></body>')).toBe(
|
||||
'<div>본문</div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('onerror 를 노리는 이미지는 태그째 사라진다', () => {
|
||||
expect(clean('<img src=x onerror="alert(1)">글자')).toBe('글자');
|
||||
});
|
||||
});
|
||||
|
||||
describe('링크', () => {
|
||||
it('http·https·mailto 링크는 새 창으로 연다', () => {
|
||||
expect(clean('<a href="https://arasystem.it.kr/notice">공지</a>')).toBe(
|
||||
'<a href="https://arasystem.it.kr/notice" target="_blank" rel="noopener noreferrer">공지</a>',
|
||||
);
|
||||
expect(clean('<a href="mailto:ara.kim@arasystem.it.kr">메일</a>')).toBe(
|
||||
'<a href="mailto:ara.kim@arasystem.it.kr" target="_blank" rel="noopener noreferrer">메일</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('원문의 target·rel 은 버리고 서버 값으로 덮는다 (window.opener 악용 방지)', () => {
|
||||
expect(
|
||||
clean('<a href="https://a.example" target="_self" rel="opener">x</a>'),
|
||||
).toBe(
|
||||
'<a href="https://a.example" target="_blank" rel="noopener noreferrer">x</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('javascript: 등 위험한 스킴은 주소만 지우고 글자는 남긴다', () => {
|
||||
for (const href of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'java	script:alert(1)',
|
||||
'javascript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'file:///etc/passwd',
|
||||
]) {
|
||||
expect(clean(`<a href="${href}">눌러</a>`)).toBe('<a>눌러</a>');
|
||||
}
|
||||
});
|
||||
|
||||
it('상대 주소·프로토콜 상대 주소도 링크로 남기지 않는다', () => {
|
||||
expect(clean('<a href="/admin">x</a>')).toBe('<a>x</a>');
|
||||
expect(clean('<a href="//evil.example">x</a>')).toBe('<a>x</a>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('스타일', () => {
|
||||
it('style 태그는 내용까지 지운다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<style>body{background:url(https://t.example/p.gif)}</style><p>본문</p>',
|
||||
),
|
||||
).toBe('<p>본문</p>');
|
||||
});
|
||||
|
||||
it('인라인 style 의 외부 url() 은 지우고 외부 이미지 차단으로 표시한다', () => {
|
||||
const result = sanitizeMailHtml(
|
||||
'<div style="background-image:url(https://t.example/p.gif);color:#333">a</div>',
|
||||
);
|
||||
|
||||
expect(result.html).toBe('<div style="color:#333">a</div>');
|
||||
expect(result.hasBlockedImages).toBe(true);
|
||||
});
|
||||
|
||||
it('url() 은 따옴표·프로토콜 상대·data: 어떤 형태든 남기지 않는다', () => {
|
||||
for (const style of [
|
||||
"background:url('//t.example/p.gif')",
|
||||
'background: url( "http://t.example/p.gif" )',
|
||||
'background:url(data:image/png;base64,AAAA)',
|
||||
'list-style:url(https://t.example/b.png)',
|
||||
'background:u\\72l(https://t.example/p.gif)',
|
||||
'background:image-set("https://t.example/p.gif" 1x)',
|
||||
]) {
|
||||
expect(clean(`<p style="${style}">a</p>`)).toBe('<p>a</p>');
|
||||
}
|
||||
});
|
||||
|
||||
it('expression()·behavior 같은 스크립트성 CSS 를 지운다', () => {
|
||||
expect(clean('<p style="width:expression(alert(1))">a</p>')).toBe(
|
||||
'<p>a</p>',
|
||||
);
|
||||
expect(clean('<p style="behavior:url(x.htc);color:red">a</p>')).toBe(
|
||||
'<p style="color:red">a</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('위치를 바꿔 화면을 덮는 속성은 남기지 않는다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<div style="position:fixed;top:0;z-index:9999;color:red">a</div>',
|
||||
),
|
||||
).toBe('<div style="color:red">a</div>');
|
||||
});
|
||||
|
||||
it('일반적인 메일 서식은 남긴다 (색 함수·한글 글꼴·여백)', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<p style="color:rgb(10, 20, 30);font-family:'맑은 고딕', sans-serif;margin:0 auto;font:12px/1.5 Arial">a</p>`,
|
||||
),
|
||||
).toBe(
|
||||
`<p style="color:rgb(10, 20, 30);font-family:'맑은 고딕', sans-serif;margin:0 auto;font:12px/1.5 Arial">a</p>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('이미지', () => {
|
||||
it('외부 이미지는 지우고 hasBlockedImages 를 켠다 (추적 픽셀 차단)', () => {
|
||||
const result = sanitizeMailHtml(
|
||||
'<p>안녕</p><img src="https://tracker.example/open.gif" width="1" height="1">',
|
||||
);
|
||||
|
||||
expect(result.html).toBe('<p>안녕</p>');
|
||||
expect(result.hasBlockedImages).toBe(true);
|
||||
});
|
||||
|
||||
it('프로토콜 상대 주소·srcset 의 외부 이미지도 차단한다', () => {
|
||||
expect(
|
||||
sanitizeMailHtml('<img src="//cdn.example/a.png">').hasBlockedImages,
|
||||
).toBe(true);
|
||||
expect(
|
||||
sanitizeMailHtml('<img srcset="https://cdn.example/a.png 2x">')
|
||||
.hasBlockedImages,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('표 배경(background 속성)의 외부 이미지도 지우고 표시한다', () => {
|
||||
const result = sanitizeMailHtml(
|
||||
'<table><tr><td background="https://t.example/bg.png">a</td></tr></table>',
|
||||
);
|
||||
|
||||
expect(result.html).toBe('<table><tr><td>a</td></tr></table>');
|
||||
expect(result.hasBlockedImages).toBe(true);
|
||||
});
|
||||
|
||||
it('cid: 이미지는 준비된 data: URI 로 바꾼다 (대소문자·꺾쇠·퍼센트 인코딩 무관)', () => {
|
||||
const inline = new Map([['logo@arasystem.it.kr', PNG_DATA_URI]]);
|
||||
|
||||
expect(
|
||||
clean('<img src="cid:logo@arasystem.it.kr" alt="로고">', inline),
|
||||
).toBe(`<img alt="로고" src="${PNG_DATA_URI}" />`);
|
||||
expect(clean('<img src="CID:Logo%40AraSystem.it.kr">', inline)).toBe(
|
||||
`<img src="${PNG_DATA_URI}" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it('준비되지 않은(큰·형식 불일치) cid: 이미지는 지우되 외부 이미지로 표시하지 않는다', () => {
|
||||
const result = sanitizeMailHtml('<p>a</p><img src="cid:big@x">');
|
||||
|
||||
expect(result.html).toBe('<p>a</p>');
|
||||
expect(result.hasBlockedImages).toBe(false);
|
||||
});
|
||||
|
||||
it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => {
|
||||
expect(clean(`<img src="${PNG_DATA_URI}">`)).toBe(
|
||||
`<img src="${PNG_DATA_URI}" />`,
|
||||
);
|
||||
expect(
|
||||
clean('<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">'),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('외부 이미지가 없으면 hasBlockedImages 는 false 다', () => {
|
||||
expect(sanitizeMailHtml('<p>텍스트만</p>').hasBlockedImages).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('위험한 요소', () => {
|
||||
it.each([
|
||||
[
|
||||
'form 의 입력 요소',
|
||||
'<form action="https://evil.example"><input name="pw"><button>보내기</button></form>',
|
||||
],
|
||||
['iframe', '<iframe src="https://evil.example">대체 글자</iframe>'],
|
||||
['object', '<object data="x.swf">대체 글자</object>'],
|
||||
['embed', '<embed src="x.swf">'],
|
||||
['svg', '<svg><script>alert(1)</script><text>글자</text></svg>'],
|
||||
['math', '<math><mtext>글자</mtext></math>'],
|
||||
['video', '<video src="https://evil.example/v.mp4">대체 글자</video>'],
|
||||
[
|
||||
'base·meta·link',
|
||||
'<base href="https://evil.example/"><meta http-equiv="refresh" content="0;url=https://evil.example"><link rel="stylesheet" href="https://evil.example/x.css">',
|
||||
],
|
||||
])('%s 은(는) 남지 않는다', (_name, html) => {
|
||||
const output = clean(`<p>본문</p>${html}`);
|
||||
|
||||
expect(output).toBe('<p>본문</p>');
|
||||
});
|
||||
|
||||
it('깊게 중첩한 iframe 도 스택 넘침 없이 통째로 버린다 — GHSA-c9rx-x947-fp3h(2.18.0) 에 닿지 않는 설정', () => {
|
||||
// sanitize-html 2.18.0 기본 설정은 버린 iframe 의 대체 내용을 다시 정화하다 1만 단계쯤에서 스택이 넘친다(확인함).
|
||||
// 우리는 iframe 을 내용째 버리므로 그 경로를 타지 않는다 — iframe 을 허용하거나 내용을 남기게 바꾸면 이 테스트가 막는다
|
||||
const depth = 20_000;
|
||||
const nested = `${'<iframe>'.repeat(depth)}<p>x</p>${'</iframe>'.repeat(depth)}`;
|
||||
|
||||
expect(clean(`<p>본문</p>${nested}`)).toBe('<p>본문</p>');
|
||||
});
|
||||
|
||||
it('sanitize-html 보안 권고 4건의 재현 입력이 통하지 않는다 (svg·textarea·xmp·meta·noscript 비허용 — 버전을 올려도 유지)', () => {
|
||||
for (const payload of [
|
||||
'<svg><textarea><img src=x onerror=alert(1)></textarea></svg>',
|
||||
'<textarea></textarea/><img src=x onerror=alert(1)>',
|
||||
'<xmp></xmp/><img src=x onerror=alert(1)>',
|
||||
'<meta http-equiv="refresh" content="0;url=javascript:alert(1)">',
|
||||
'<svg><animate attributeName="href" values="javascript:alert(1)"/></svg>',
|
||||
'<noscript><p title="</noscript><img src=x onerror=alert(1)>"></noscript>',
|
||||
]) {
|
||||
const output = clean(payload);
|
||||
expect(output).not.toMatch(
|
||||
/onerror|javascript:|<img|<svg|<meta|<textarea/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('주석(조건부 주석 포함)을 지운다', () => {
|
||||
expect(clean('<!--[if mso]><p>아웃룩</p><![endif]--><p>본문</p>')).toBe(
|
||||
'<p>본문</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('허용하지 않은 태그는 태그만 지우고 글자는 남긴다', () => {
|
||||
expect(clean('<marquee>흐르는 글자</marquee>')).toBe('흐르는 글자');
|
||||
});
|
||||
|
||||
it('id·class·name 속성은 남기지 않는다 (DOM 충돌 방지)', () => {
|
||||
expect(clean('<div id="app" class="x" name="y">a</div>')).toBe(
|
||||
'<div>a</div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML 메일 레이아웃', () => {
|
||||
it('표·글꼴의 표시용 속성은 남긴다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="#ffffff"><tr><td colspan="2" valign="top"><font color="#333333" face="Arial" size="2">안내</font></td></tr></table>',
|
||||
),
|
||||
).toBe(
|
||||
'<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="#ffffff"><tr><td colspan="2" valign="top"><font color="#333333" face="Arial" size="2">안내</font></td></tr></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('html·head·body 껍데기를 벗기고 본문만 남긴다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<html><head><title>제목</title><meta charset="utf-8"></head><body><p>본문</p></body></html>',
|
||||
),
|
||||
).toBe('<p>본문</p>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { normalizeContentId } from './inline-images';
|
||||
|
||||
/**
|
||||
* 받은 메일 HTML 정화 (순수 함수) — 계약 2절 "htmlBody 정화 규칙".
|
||||
*
|
||||
* 메일 엔진은 HTML 을 정화하지 않는다(`<script>` 가 그대로 온다). 정화는 백엔드 책임이고,
|
||||
* 클라이언트는 그래도 격리된 iframe(스크립트 비허용 sandbox + 내부 CSP)에만 그린다 — 이중 방어.
|
||||
*
|
||||
* - 허용 목록 방식: 아래에 없는 태그·속성은 모두 버린다
|
||||
* - 스크립트·스타일 태그·폼·iframe·object·embed·svg 는 **내용까지** 버린다
|
||||
* - 이벤트 핸들러(on*)·id·class 는 허용 속성이 아니므로 버린다
|
||||
* - 링크는 http·https·mailto 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`)
|
||||
* - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단)
|
||||
* - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다
|
||||
*
|
||||
* sanitize-html 2.18 (런타임 Node 24) — 2.17.5 고정을 풀면서 보안 권고 4건(GHSA-jxwj-j7wr-gfrw·GHSA-g8qq-57p8-ggw5·
|
||||
* GHSA-cv27-6wvh-8x7j·GHSA-x3q4-9hxx-gx8m)이 해결됐다. 그래도 svg·math·textarea·xmp·option·meta·noscript 는
|
||||
* 허용 목록에 넣지 않는다 — 우회 기법이 반복해서 나온 태그들이다 (sanitize-mail-html.spec.ts 가 지킨다).
|
||||
* ⚠️ 2.18 이 쓰는 htmlparser2 12 계열은 ESM 전용이다. Node 24 는 require 로 그대로 읽지만 Jest(CJS)는 못 읽어서,
|
||||
* jest 설정(package.json·test/jest-e2e.json)의 transform 이 그 6개 패키지만 CJS 로 바꿔 읽는다.
|
||||
* 패키지를 올리다 "Cannot use import statement outside a module" 가 나면 그 목록에 ESM 전용 패키지를 더한다.
|
||||
*/
|
||||
|
||||
export interface SanitizedMailHtml {
|
||||
readonly html: string;
|
||||
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
|
||||
readonly hasBlockedImages: boolean;
|
||||
}
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
// 구획
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'footer',
|
||||
'header',
|
||||
'main',
|
||||
'nav',
|
||||
'section',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
// 본문
|
||||
'blockquote',
|
||||
'center',
|
||||
'dd',
|
||||
'details',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'hr',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'summary',
|
||||
'ul',
|
||||
// 인라인
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'bdi',
|
||||
'bdo',
|
||||
'big',
|
||||
'br',
|
||||
'cite',
|
||||
'code',
|
||||
'del',
|
||||
'dfn',
|
||||
'em',
|
||||
'font',
|
||||
'i',
|
||||
'img',
|
||||
'ins',
|
||||
'kbd',
|
||||
'mark',
|
||||
'q',
|
||||
'rp',
|
||||
'rt',
|
||||
'ruby',
|
||||
's',
|
||||
'samp',
|
||||
'small',
|
||||
'span',
|
||||
'strike',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'time',
|
||||
'tt',
|
||||
'u',
|
||||
'var',
|
||||
'wbr',
|
||||
// 표 (HTML 메일의 레이아웃은 대부분 표다)
|
||||
'caption',
|
||||
'col',
|
||||
'colgroup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
];
|
||||
|
||||
/**
|
||||
* 태그를 버릴 때 **내용까지** 버리는 태그.
|
||||
* 여기에 없는 비허용 태그는 태그만 버리고 안의 글자는 (정화해서) 남긴다.
|
||||
* sanitize-html 기본값(script·style·textarea·option·xmp)을 모두 포함해야 한다.
|
||||
*/
|
||||
const NON_TEXT_TAGS = [
|
||||
'script',
|
||||
'style',
|
||||
'textarea',
|
||||
'option',
|
||||
'xmp',
|
||||
'noscript',
|
||||
'head',
|
||||
'title',
|
||||
'template',
|
||||
'svg',
|
||||
'math',
|
||||
'iframe',
|
||||
'frame',
|
||||
'frameset',
|
||||
'noframes',
|
||||
'object',
|
||||
'embed',
|
||||
'applet',
|
||||
'noembed',
|
||||
'canvas',
|
||||
'audio',
|
||||
'video',
|
||||
'map',
|
||||
'select',
|
||||
'button',
|
||||
'datalist',
|
||||
];
|
||||
|
||||
/** 속성 허용 목록 — id·name·class 는 넣지 않는다 (DOM 충돌·스타일 우회 방지) */
|
||||
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
'*': [
|
||||
'style',
|
||||
'title',
|
||||
'dir',
|
||||
'lang',
|
||||
'align',
|
||||
'valign',
|
||||
'width',
|
||||
'height',
|
||||
'bgcolor',
|
||||
],
|
||||
a: ['href', 'target', 'rel'],
|
||||
img: ['src', 'alt', 'border'],
|
||||
table: ['border', 'cellpadding', 'cellspacing', 'summary'],
|
||||
td: ['colspan', 'rowspan', 'nowrap'],
|
||||
th: ['colspan', 'rowspan', 'nowrap', 'scope'],
|
||||
col: ['span'],
|
||||
colgroup: ['span'],
|
||||
font: ['color', 'face', 'size'],
|
||||
ol: ['start', 'type', 'reversed'],
|
||||
ul: ['type'],
|
||||
li: ['value'],
|
||||
time: ['datetime'],
|
||||
del: ['datetime'],
|
||||
ins: ['datetime'],
|
||||
details: ['open'],
|
||||
};
|
||||
|
||||
const BOX_SIDES = ['', '-top', '-right', '-bottom', '-left'];
|
||||
|
||||
/** 인라인 style 에서 남기는 표시용 속성 — position·z-index·content 등은 넣지 않는다 */
|
||||
const ALLOWED_STYLE_PROPERTIES = [
|
||||
'color',
|
||||
'background',
|
||||
'background-color',
|
||||
'font',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-variant',
|
||||
'font-weight',
|
||||
'letter-spacing',
|
||||
'line-height',
|
||||
'word-spacing',
|
||||
'white-space',
|
||||
'word-break',
|
||||
'word-wrap',
|
||||
'overflow-wrap',
|
||||
'text-align',
|
||||
'text-decoration',
|
||||
'text-decoration-color',
|
||||
'text-decoration-line',
|
||||
'text-decoration-style',
|
||||
'text-indent',
|
||||
'text-transform',
|
||||
'text-overflow',
|
||||
'vertical-align',
|
||||
'direction',
|
||||
'width',
|
||||
'min-width',
|
||||
'max-width',
|
||||
'height',
|
||||
'min-height',
|
||||
'max-height',
|
||||
...BOX_SIDES.map((side) => `margin${side}`),
|
||||
...BOX_SIDES.map((side) => `padding${side}`),
|
||||
...BOX_SIDES.flatMap((side) => [
|
||||
`border${side}`,
|
||||
`border${side}-color`,
|
||||
`border${side}-style`,
|
||||
`border${side}-width`,
|
||||
]),
|
||||
'border-collapse',
|
||||
'border-spacing',
|
||||
'border-radius',
|
||||
'display',
|
||||
'float',
|
||||
'clear',
|
||||
'overflow',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'list-style',
|
||||
'list-style-type',
|
||||
'list-style-position',
|
||||
'table-layout',
|
||||
'caption-side',
|
||||
'empty-cells',
|
||||
];
|
||||
|
||||
/**
|
||||
* 안전한 CSS 값 — 허용 문자만으로 이뤄지고, 괄호는 색·계산·그라데이션 함수에만 쓴다.
|
||||
* url()·image-set()·expression() 같은 함수와 `:`(스킴)·`\`(CSS 이스케이프)·주석은 모두 걸러진다.
|
||||
* 한글 글꼴 이름("맑은 고딕")을 위해 유니코드 글자를 허용한다.
|
||||
*/
|
||||
const SAFE_CSS_VALUE =
|
||||
/^(?:[-\p{L}\p{N}_\s#%.,+!/'"]|\)|(?<=\b(?:rgba?|hsla?|calc|(?:repeating-)?(?:linear|radial)-gradient)\s*)\()*$/iu;
|
||||
|
||||
const ALLOWED_STYLES = {
|
||||
'*': Object.fromEntries(
|
||||
ALLOWED_STYLE_PROPERTIES.map((property) => [property, [SAFE_CSS_VALUE]]),
|
||||
),
|
||||
};
|
||||
|
||||
/** 링크로 남길 수 있는 주소 */
|
||||
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i;
|
||||
|
||||
/** 외부에서 불러오는 주소 (프로토콜 상대 `//host` 포함) */
|
||||
const EXTERNAL_URL = /^\s*(?:https?:)?\/\//i;
|
||||
|
||||
/** srcset·style 안의 외부 주소 */
|
||||
const EMBEDDED_EXTERNAL_URL = /(?:https?:)?\/\//i;
|
||||
|
||||
/** style 의 url() 이 외부 주소를 가리키는지 */
|
||||
const CSS_EXTERNAL_URL = /url\s*\(\s*['"]?\s*(?:https?:)?\/\//i;
|
||||
|
||||
/** 원문에 이미 들어 있던 data: 이미지 중 남겨도 되는 형식 */
|
||||
const SAFE_DATA_IMAGE =
|
||||
/^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/]+={0,2}$/i;
|
||||
|
||||
function secureLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Attributes {
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
delete next.href;
|
||||
delete next.target;
|
||||
delete next.rel;
|
||||
|
||||
const href = attribs.href?.trim();
|
||||
if (href && SAFE_LINK.test(href)) {
|
||||
next.href = href;
|
||||
next.target = '_blank';
|
||||
next.rel = 'noopener noreferrer';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveImage(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
inlineImages: ReadonlyMap<string, string>,
|
||||
markBlocked: () => void,
|
||||
): sanitizeHtml.Attributes {
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
delete next.src;
|
||||
delete next.srcset;
|
||||
|
||||
if (attribs.srcset && EMBEDDED_EXTERNAL_URL.test(attribs.srcset)) {
|
||||
markBlocked();
|
||||
}
|
||||
|
||||
const src = attribs.src?.trim() ?? '';
|
||||
if (/^cid:/i.test(src)) {
|
||||
const dataUri = inlineImages.get(normalizeContentId(src.slice(4)));
|
||||
if (dataUri) next.src = dataUri;
|
||||
return next;
|
||||
}
|
||||
if (SAFE_DATA_IMAGE.test(src)) {
|
||||
next.src = src;
|
||||
return next;
|
||||
}
|
||||
if (EXTERNAL_URL.test(src)) markBlocked();
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 배경 이미지로 외부 주소를 불러오려는 속성 (지워지지만 안내를 위해 표시한다) */
|
||||
function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
|
||||
return (
|
||||
(attribs.background !== undefined &&
|
||||
EXTERNAL_URL.test(attribs.background)) ||
|
||||
(attribs.style !== undefined && CSS_EXTERNAL_URL.test(attribs.style))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 HTML 을 정화한다.
|
||||
* @param inlineImages 정규화한 Content-ID → data: URI (서비스 계층이 크기·형식을 확인해 만든 것)
|
||||
*/
|
||||
export function sanitizeMailHtml(
|
||||
html: string,
|
||||
inlineImages: ReadonlyMap<string, string> = new Map(),
|
||||
): SanitizedMailHtml {
|
||||
let hasBlockedImages = false;
|
||||
const markBlocked = () => {
|
||||
hasBlockedImages = true;
|
||||
};
|
||||
|
||||
const output = sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
// 스킴은 태그별 목록만 쓴다 — 그 밖의 URL 속성(background·cite 등)은 모두 거부된다
|
||||
allowedSchemes: [],
|
||||
allowedSchemesByTag: { a: ['http', 'https', 'mailto'], img: ['data'] },
|
||||
allowProtocolRelative: false,
|
||||
allowedStyles: ALLOWED_STYLES,
|
||||
nonTextTags: NON_TEXT_TAGS,
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
|
||||
img: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: resolveImage(attribs, inlineImages, markBlocked),
|
||||
}),
|
||||
'*': (tagName, attribs) => {
|
||||
if (loadsExternalImage(attribs)) markBlocked();
|
||||
return { tagName, attribs };
|
||||
},
|
||||
},
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지)
|
||||
exclusiveFilter: (frame) => frame.tag === 'img' && !frame.attribs.src,
|
||||
});
|
||||
|
||||
return { html: output, hasBlockedImages };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
|
||||
const inbox = { mailboxId: 'a' };
|
||||
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('메일 목록 커서', () => {
|
||||
it('인코딩한 위치를 그대로 되돌린다', () => {
|
||||
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, inbox);
|
||||
|
||||
expect(decodeCursor(cursor, inbox)).toEqual({
|
||||
anchor: 'eaaaaab',
|
||||
position: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('URL 에 그대로 넣을 수 있는 문자만 쓴다', () => {
|
||||
const cursor = encodeCursor(
|
||||
{ anchor: 'eaaaaab', position: 20 },
|
||||
{ mailboxId: 'a', text: '메일 서버 이전' },
|
||||
);
|
||||
|
||||
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('다른 메일함이나 다른 검색어의 커서는 VAL_001 이다', () => {
|
||||
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, inbox);
|
||||
|
||||
expect(codeOf(() => decodeCursor(cursor, { mailboxId: 'b' }))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(
|
||||
codeOf(() => decodeCursor(cursor, { mailboxId: 'a', text: '이전' })),
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('해석할 수 없는 값은 VAL_001 이다', () => {
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({ v: 1, a: '../x', p: -1, s: 'x' }),
|
||||
).toString('base64url');
|
||||
|
||||
for (const cursor of ['not-a-cursor', '', forged, 'a'.repeat(600)]) {
|
||||
expect(codeOf(() => decodeCursor(cursor, inbox))).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
|
||||
/**
|
||||
* 메일 목록 커서 (클라이언트에게는 불투명 문자열).
|
||||
*
|
||||
* JMAP `Email/query` 는 마지막으로 본 메일 id(anchor) 다음부터 이어 읽는다.
|
||||
* 그 메일이 그 사이에 지워지거나 옮겨지면 `anchorNotFound` 가 나므로,
|
||||
* 다음 위치(position)도 함께 넣어 두었다가 그때는 위치로 다시 읽는다.
|
||||
*
|
||||
* 다른 목록(메일함·검색어)의 커서를 섞어 쓰지 못하도록 목록 범위의 해시를 함께 넣는다.
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
|
||||
/** 커서 문자열 최대 길이 — 이보다 길면 해석하지 않는다 */
|
||||
const MAX_CURSOR_LENGTH = 512;
|
||||
|
||||
/** 범위 해시 길이 (충돌보다는 "다른 목록의 커서" 감지가 목적이라 짧게 둔다) */
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
|
||||
export interface CursorScope {
|
||||
readonly mailboxId: string;
|
||||
readonly text?: string;
|
||||
}
|
||||
|
||||
export interface CursorPosition {
|
||||
/** 마지막으로 내려준 메일 id */
|
||||
readonly anchor: string;
|
||||
/** 다음 페이지 첫 항목의 위치 (anchor 를 못 찾을 때 대신 쓴다) */
|
||||
readonly position: number;
|
||||
}
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
a: string;
|
||||
p: number;
|
||||
s: string;
|
||||
}
|
||||
|
||||
function scopeHash(scope: CursorScope): string {
|
||||
return createHash('sha256')
|
||||
.update(`${scope.mailboxId}\n${scope.text ?? ''}`)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
export function encodeCursor(
|
||||
position: CursorPosition,
|
||||
scope: CursorScope,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
a: position.anchor,
|
||||
p: position.position,
|
||||
s: scopeHash(scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 해석한다. 형식이 틀리거나 다른 목록의 커서면 VAL_001 */
|
||||
export function decodeCursor(
|
||||
cursor: string,
|
||||
scope: CursorScope,
|
||||
): CursorPosition {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor('not-json');
|
||||
}
|
||||
|
||||
const { v, a, p, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
typeof a === 'string' &&
|
||||
MAIL_ID_PATTERN.test(a) &&
|
||||
typeof p === 'number' &&
|
||||
Number.isSafeInteger(p) &&
|
||||
p >= 0 &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('shape');
|
||||
|
||||
if (s !== scopeHash(scope)) throw invalidCursor('other-list');
|
||||
|
||||
return { anchor: a, position: p };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,986 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { openAsBlob } from 'fs';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
MailAccountProfile,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailUsage,
|
||||
MessageDetailSource,
|
||||
MessageListQuery,
|
||||
MessagePage,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingAttachment,
|
||||
OutgoingMessage,
|
||||
SentMessageRecord,
|
||||
UploadedFile,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
JMAP_SEND_TIMEOUT_MS,
|
||||
JmapClient,
|
||||
type JmapSession,
|
||||
} from './jmap.client';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { isMissingAttachmentBlob, jmapFailure } from './jmap.errors';
|
||||
import {
|
||||
BODY_PART_PROPERTIES,
|
||||
DETAIL_PROPERTIES,
|
||||
MAILBOX_PROPERTIES,
|
||||
SUMMARY_PROPERTIES,
|
||||
fallbackDisplayName,
|
||||
findAttachment,
|
||||
pickIdentity,
|
||||
selectAccountQuota,
|
||||
toDetailSource,
|
||||
toMailbox,
|
||||
toSummary,
|
||||
toUsage,
|
||||
} from './jmap.mapper';
|
||||
import type {
|
||||
JmapBlob,
|
||||
JmapEmail,
|
||||
JmapError,
|
||||
JmapGetResult,
|
||||
JmapIdentity,
|
||||
JmapInvocation,
|
||||
JmapMailbox,
|
||||
JmapQueryResult,
|
||||
JmapQuota,
|
||||
JmapSetResult,
|
||||
} from './jmap.types';
|
||||
|
||||
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
|
||||
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
|
||||
const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
|
||||
|
||||
/**
|
||||
* 발송 때 남기는 내용 지문 헤더 — 같은 Idempotency-Key 로 내용이 바뀌었는지 비교한다.
|
||||
* 메일과 함께 받는 사람에게도 가지만 Idempotency-Key(무작위)로 만든 HMAC 이라
|
||||
* 키를 모르는 받는 사람은 내용(숨은 참조 등)을 맞춰 볼 수 없다 (send-idempotency.ts 머리말).
|
||||
*/
|
||||
const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
|
||||
const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
|
||||
|
||||
/**
|
||||
* 중복 발송 확인 때 훑는 최근 메일 수 (보낸편지함 + 임시보관함, 받은 시각 내림차순).
|
||||
* 재시도는 같은 쓰기 화면에서 곧 오므로 충분하다 — 그 사이 이보다 많이 보냈다면 다시 보낸다.
|
||||
*/
|
||||
const IDEMPOTENCY_LOOKBACK = 200;
|
||||
|
||||
/**
|
||||
* 쿼터가 없는 계정의 사용량은 메일 크기를 더해 구한다 (대리 접근 권한으로는 엔진의 계정 사용량을 읽을 수 없다).
|
||||
* 한 번에 읽는 개수는 엔진의 maxObjectsInGet(500) 이하, 비용이 커서 결과를 직원별로 잠깐 캐시한다.
|
||||
*/
|
||||
const USAGE_SCAN_PAGE_SIZE = 500;
|
||||
/** 이보다 많으면 더 세지 않는다 (경고 로그 — 실제 사용량보다 작게 보인다) */
|
||||
const USAGE_SCAN_MAX_EMAILS = 50_000;
|
||||
const USAGE_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/** 상세 조회 때 본문 값을 읽어 오는 상한 — 넘으면 엔진이 잘라서 준다 */
|
||||
const MAX_BODY_VALUE_BYTES = 1_000_000;
|
||||
|
||||
/** References 헤더에 이어 붙이는 Message-ID 개수 상한 (긴 스레드에서 헤더가 끝없이 커지지 않게) */
|
||||
const MAX_REFERENCES = 20;
|
||||
|
||||
/** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */
|
||||
const DOWNLOAD_TYPE = 'application/octet-stream';
|
||||
|
||||
type MethodResult =
|
||||
| { ok: true; args: Record<string, unknown> }
|
||||
| { ok: false; error: JmapError };
|
||||
|
||||
/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */
|
||||
function findResult(
|
||||
responses: JmapInvocation[],
|
||||
callId: string,
|
||||
method: string,
|
||||
): MethodResult {
|
||||
for (const [name, args, id] of responses) {
|
||||
if (id !== callId) continue;
|
||||
if (name === 'error') return { ok: false, error: args };
|
||||
if (name === method) return { ok: true, args };
|
||||
}
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'missing-response', method },
|
||||
});
|
||||
}
|
||||
|
||||
/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */
|
||||
function expectResult<T>(
|
||||
responses: JmapInvocation[],
|
||||
callId: string,
|
||||
method: string,
|
||||
): T {
|
||||
const result = findResult(responses, callId, method);
|
||||
if (!result.ok) throw jmapFailure(result.error, { method });
|
||||
return result.args as T;
|
||||
}
|
||||
|
||||
/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */
|
||||
function firstSetError(
|
||||
errors: Record<string, JmapError> | undefined,
|
||||
): JmapError | undefined {
|
||||
return errors ? Object.values(errors)[0] : undefined;
|
||||
}
|
||||
|
||||
function toJmapAddress(address: MailAddress): {
|
||||
name: string | null;
|
||||
email: string;
|
||||
} {
|
||||
return { name: address.name, email: address.email };
|
||||
}
|
||||
|
||||
function roleMailboxId(
|
||||
mailboxes: JmapMailbox[],
|
||||
role: string,
|
||||
): string | undefined {
|
||||
return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id;
|
||||
}
|
||||
|
||||
/** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */
|
||||
export function buildThreadHeaders(
|
||||
original: Pick<JmapEmail, 'messageId' | 'references'> | undefined,
|
||||
): { inReplyTo: string[]; references: string[] } | null {
|
||||
const messageIds = original?.messageId ?? [];
|
||||
if (messageIds.length === 0) return null;
|
||||
|
||||
const references = [
|
||||
...new Set([...(original?.references ?? []), ...messageIds]),
|
||||
].slice(-MAX_REFERENCES);
|
||||
return { inReplyTo: messageIds, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart(JMAP) 어댑터 — `MailProvider` 의 구현.
|
||||
* JMAP 의 메서드·속성·오류 형태는 이 파일과 `jmap/` 안에서만 다룬다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapMailProvider implements MailProvider {
|
||||
private readonly logger = new Logger(JmapMailProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly jmap: JmapClient,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
) {}
|
||||
|
||||
verifyCredentials(email: string, password: string): Promise<string | null> {
|
||||
return this.jmap.verifyCredentials(email, password);
|
||||
}
|
||||
|
||||
async getProfile(
|
||||
user: string,
|
||||
options: { fresh?: boolean } = {},
|
||||
): Promise<MailAccountProfile> {
|
||||
const session = await this.jmap.getSession(user, options);
|
||||
// 로그인·리프레시(fresh)에서만 — 관리 권한이 있는 계정은 웹메일로 다루지 않는다 (있으면 AUTH_001)
|
||||
if (options.fresh) await this.jmap.assertNoManagementAccess(user);
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Identity/get', { accountId, ids: null }, 'i'],
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapIdentity>>(
|
||||
responses,
|
||||
'i',
|
||||
'Identity/get',
|
||||
);
|
||||
|
||||
const name = pickIdentity(list, session.username)?.name?.trim();
|
||||
return {
|
||||
email: session.username,
|
||||
name: name || fallbackDisplayName(session.username),
|
||||
};
|
||||
}
|
||||
|
||||
async listMailboxes(user: string): Promise<Mailbox[]> {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: MAILBOX_PROPERTIES },
|
||||
'm',
|
||||
],
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
return list.map(toMailbox);
|
||||
}
|
||||
|
||||
async getUsage(user: string): Promise<MailUsage> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [['Quota/get', { accountId, ids: null }, 'qt']],
|
||||
{ capabilities: [QUOTA_CAPABILITY] },
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapQuota>>(
|
||||
responses,
|
||||
'qt',
|
||||
'Quota/get',
|
||||
);
|
||||
|
||||
const quota = selectAccountQuota(list);
|
||||
if (quota) return toUsage(quota);
|
||||
|
||||
// 계정 쿼터가 없다 = 한도 없음. 사용량은 메일 크기 합계로 대신한다 (연락처·일정 등은 빠진 근삿값)
|
||||
const cacheKey = `mail:usage:${user}`;
|
||||
const cached = await this.cache.get<number>(cacheKey);
|
||||
if (typeof cached === 'number') {
|
||||
return { usedBytes: cached, limitBytes: null };
|
||||
}
|
||||
|
||||
const usedBytes = await this.sumEmailSizes(user);
|
||||
await this.cache.set(cacheKey, usedBytes, USAGE_CACHE_TTL_MS);
|
||||
return { usedBytes, limitBytes: null };
|
||||
}
|
||||
|
||||
/** 발송 검사(assertAttachmentsFit)와 같은 세션·같은 함수로 정한다 — 세션은 발송 때처럼 캐시된 것을 쓴다 */
|
||||
async getAttachmentsTotalLimit(user: string): Promise<number> {
|
||||
return this.attachmentsTotalLimit(await this.jmap.getSession(user));
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
): Promise<MessagePage> {
|
||||
const scope = { mailboxId: query.mailboxId, text: query.text };
|
||||
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
|
||||
|
||||
let responses = await this.queryMessages(
|
||||
user,
|
||||
query,
|
||||
resume ? { anchor: resume.anchor } : { position: 0 },
|
||||
);
|
||||
|
||||
// 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
|
||||
const first = findResult(responses, 'q', 'Email/query');
|
||||
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
|
||||
responses = await this.queryMessages(user, query, {
|
||||
position: resume.position,
|
||||
});
|
||||
}
|
||||
|
||||
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'mb',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailbox.list?.length) {
|
||||
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
|
||||
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
|
||||
const ids = result.ids ?? [];
|
||||
const start = result.position ?? 0;
|
||||
const total = typeof result.total === 'number' ? result.total : null;
|
||||
|
||||
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
const items = ids.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email)] : [];
|
||||
});
|
||||
|
||||
const hasNext =
|
||||
total !== null ? start + ids.length < total : ids.length === query.limit;
|
||||
const lastId = ids.at(-1);
|
||||
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && lastId
|
||||
? encodeCursor(
|
||||
{ anchor: lastId, position: start + ids.length },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessage(user: string, id: string): Promise<MessageDetailSource> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [id],
|
||||
properties: DETAIL_PROPERTIES,
|
||||
bodyProperties: BODY_PART_PROPERTIES,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
|
||||
return toDetailSource(this.requireEmail(responses, 'g'));
|
||||
}
|
||||
|
||||
async updateMessage(
|
||||
user: string,
|
||||
id: string,
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (update.isRead !== undefined) {
|
||||
patch['keywords/$seen'] = update.isRead ? true : null;
|
||||
}
|
||||
if (update.isFlagged !== undefined) {
|
||||
patch['keywords/$flagged'] = update.isFlagged ? true : null;
|
||||
}
|
||||
if (update.mailboxId !== undefined) {
|
||||
// 이동 = 이 메일함 하나에만 속하게 한다
|
||||
patch.mailboxIds = { [update.mailboxId]: true };
|
||||
}
|
||||
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
['Email/set', { accountId, update: { [id]: patch } }, 'u'],
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: [id], properties: SUMMARY_PROPERTIES },
|
||||
'g',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
|
||||
const set = expectResult<JmapSetResult>(responses, 'u', 'Email/set');
|
||||
const failure = firstSetError(set.notUpdated);
|
||||
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
|
||||
|
||||
return toSummary(this.requireEmail(responses, 'g'));
|
||||
}
|
||||
|
||||
async deleteMessage(user: string, id: string): Promise<void> {
|
||||
const lookup = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: [id], properties: ['id', 'mailboxIds'] },
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
|
||||
const email = this.requireEmail(lookup, 'g');
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
lookup,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const trashId = roleMailboxId(mailboxes, 'trash');
|
||||
if (!trashId) {
|
||||
// 휴지통 없이 지우면 되돌릴 수 없다 — 영구 삭제로 대신하지 않는다
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'trash-mailbox-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const inTrash = email.mailboxIds?.[trashId] === true;
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/set',
|
||||
inTrash
|
||||
? { accountId, destroy: [email.id] }
|
||||
: {
|
||||
accountId,
|
||||
update: { [email.id]: { mailboxIds: { [trashId]: true } } },
|
||||
},
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
|
||||
const set = expectResult<JmapSetResult>(responses, 'd', 'Email/set');
|
||||
const failure = firstSetError(inTrash ? set.notDestroyed : set.notUpdated);
|
||||
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
user: string,
|
||||
message: OutgoingMessage,
|
||||
): Promise<{ id: string }> {
|
||||
// 1) 준비 — 메일함 역할, 발신 ID, (답장이면) 원 메일의 스레드 헤더
|
||||
const session = await this.jmap.getSession(user);
|
||||
const { inReplyTo } = message;
|
||||
const prepared = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
['Identity/get', { accountId, ids: null }, 'i'],
|
||||
...(inReplyTo
|
||||
? [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [inReplyTo],
|
||||
properties: ['messageId', 'references'],
|
||||
},
|
||||
'r',
|
||||
] as JmapInvocation,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
{ invalidRequestCode: 'VAL_001' },
|
||||
);
|
||||
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
prepared,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const draftsId = roleMailboxId(mailboxes, 'drafts');
|
||||
const sentId = roleMailboxId(mailboxes, 'sent');
|
||||
if (!draftsId || !sentId) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'drafts-or-sent-mailbox-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const { list: identities = [] } = expectResult<JmapGetResult<JmapIdentity>>(
|
||||
prepared,
|
||||
'i',
|
||||
'Identity/get',
|
||||
);
|
||||
const identity = pickIdentity(identities, session.username);
|
||||
if (!identity) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'identity-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const thread = inReplyTo ? this.threadHeaders(prepared) : null;
|
||||
|
||||
// 2) 첨부 합계 확인 — 엔진에 메일을 만들기 전에 (없는 첨부 MAIL_004, 한도 초과 FILE_002)
|
||||
await this.assertAttachmentsFit(user, message.attachments, session);
|
||||
|
||||
// 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다
|
||||
const draft = {
|
||||
mailboxIds: { [draftsId]: true },
|
||||
keywords: { $draft: true, $seen: true },
|
||||
// 중복 발송 방지 — 같은 키의 재시도는 같은 Message-ID 로 이 메일을 다시 찾는다
|
||||
...(message.messageId ? { messageId: [message.messageId] } : {}),
|
||||
...(message.fingerprint
|
||||
? { [FINGERPRINT_PROPERTY]: message.fingerprint }
|
||||
: {}),
|
||||
from: [
|
||||
{
|
||||
name: identity.name || null,
|
||||
email: identity.email ?? session.username,
|
||||
},
|
||||
],
|
||||
to: message.to.map(toJmapAddress),
|
||||
...(message.cc.length ? { cc: message.cc.map(toJmapAddress) } : {}),
|
||||
...(message.bcc.length ? { bcc: message.bcc.map(toJmapAddress) } : {}),
|
||||
subject: message.subject,
|
||||
...(thread ?? {}),
|
||||
bodyValues: { body: { value: message.body } },
|
||||
textBody: [{ partId: 'body', type: 'text/plain' }],
|
||||
...(message.attachments.length
|
||||
? {
|
||||
attachments: message.attachments.map((attachment) => ({
|
||||
blobId: attachment.id,
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
disposition: 'attachment',
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
['Email/set', { accountId, create: { draft } }, 'e'],
|
||||
[
|
||||
'EmailSubmission/set',
|
||||
{
|
||||
accountId,
|
||||
create: { send: { identityId: identity.id, emailId: '#draft' } },
|
||||
onSuccessUpdateEmail: {
|
||||
'#send': {
|
||||
[`mailboxIds/${draftsId}`]: null,
|
||||
[`mailboxIds/${sentId}`]: true,
|
||||
'keywords/$draft': null,
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
],
|
||||
// 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다
|
||||
{ invalidRequestCode: 'VAL_001', timeoutMs: JMAP_SEND_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const created = expectResult<JmapSetResult>(responses, 'e', 'Email/set');
|
||||
const draftFailure = firstSetError(created.notCreated);
|
||||
if (draftFailure && isMissingAttachmentBlob(draftFailure)) {
|
||||
// 만료된 업로드·잘못된 첨부 id — 클라이언트는 그 첨부를 다시 올리게 한다 (계약 2절)
|
||||
throw new BusinessException('MAIL_004', {
|
||||
context: {
|
||||
reason: 'attachment-blob-not-found',
|
||||
jmapType: draftFailure.type ?? 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (draftFailure) throw jmapFailure(draftFailure, { method: 'Email/set' });
|
||||
|
||||
const draftId = created.created?.draft?.id;
|
||||
if (!draftId) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'draft-not-created' },
|
||||
});
|
||||
}
|
||||
|
||||
const submission = findResult(responses, 's', 'EmailSubmission/set');
|
||||
const submitted = submission.ok
|
||||
? (submission.args as JmapSetResult)
|
||||
: undefined;
|
||||
if (!submitted?.created?.send) {
|
||||
// 보내지 못한 초안이 임시보관함에 남지 않게 지운다
|
||||
await this.discardDraft(user, draftId);
|
||||
throw jmapFailure(
|
||||
submission.ok ? firstSetError(submitted?.notCreated) : submission.error,
|
||||
{ method: 'EmailSubmission/set' },
|
||||
);
|
||||
}
|
||||
|
||||
this.warnIfNotMovedToSent(responses, user);
|
||||
return { id: draftId };
|
||||
}
|
||||
|
||||
async findSentMessage(
|
||||
user: string,
|
||||
messageId: string,
|
||||
): Promise<SentMessageRecord | null> {
|
||||
const lookup = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
]);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
lookup,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const searched = [
|
||||
roleMailboxId(mailboxes, 'sent'),
|
||||
roleMailboxId(mailboxes, 'drafts'),
|
||||
].filter((id): id is string => id !== undefined);
|
||||
if (searched.length === 0) return null;
|
||||
|
||||
// Stalwart 는 Message-ID 헤더 필터를 지원하지 않는다(확인함) — 최근 메일의 messageId 속성을 읽어 비교한다
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: {
|
||||
operator: 'OR',
|
||||
conditions: searched.map((inMailbox) => ({ inMailbox })),
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: IDEMPOTENCY_LOOKBACK,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'messageId', 'keywords', FINGERPRINT_PROPERTY],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const email = list.find((candidate) =>
|
||||
(candidate.messageId ?? []).includes(messageId),
|
||||
);
|
||||
if (!email) return null;
|
||||
|
||||
// 초안으로 남아 있으면 실제로 제출됐는지 확인한다 — 제출 기록이 없으면 보내지 못한 초안이다
|
||||
if (
|
||||
email.keywords?.$draft === true &&
|
||||
!(await this.wasSubmitted(user, email.id))
|
||||
) {
|
||||
await this.discardDraft(user, email.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fingerprint = (email as unknown as Record<string, unknown>)[
|
||||
FINGERPRINT_PROPERTY
|
||||
];
|
||||
return {
|
||||
id: email.id,
|
||||
fingerprint:
|
||||
typeof fingerprint === 'string' && fingerprint.trim() !== ''
|
||||
? fingerprint.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadAttachment(
|
||||
user: string,
|
||||
file: UploadedFile,
|
||||
): Promise<MailAttachment> {
|
||||
// 임시 파일을 Blob 으로 연다 — 파일을 조금씩 읽어 엔진으로 흘려보낸다 (메모리에 다 올리지 않는다)
|
||||
const content = await openAsBlob(file.path, { type: file.type });
|
||||
const blob = await this.jmap.upload(user, content, file.type);
|
||||
return {
|
||||
id: blob.blobId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: blob.size,
|
||||
};
|
||||
}
|
||||
|
||||
async openAttachment(
|
||||
user: string,
|
||||
messageId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [messageId],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: ['blobId', 'name', 'type', 'size'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
|
||||
// 그 메일에 실제로 있는 첨부인지 확인한다 — 다른 blob id 로 엉뚱한 데이터를 받지 못하게
|
||||
const attachment = findAttachment(
|
||||
this.requireEmail(responses, 'g'),
|
||||
attachmentId,
|
||||
);
|
||||
if (!attachment) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'attachment-not-in-message' },
|
||||
});
|
||||
}
|
||||
|
||||
const download = await this.jmap.download(
|
||||
user,
|
||||
attachmentId,
|
||||
attachment.name,
|
||||
DOWNLOAD_TYPE,
|
||||
);
|
||||
return {
|
||||
stream: download.stream,
|
||||
name: attachment.name,
|
||||
// Content-Length 로 쓰이므로 엔진이 알려 준 실제 길이만 쓴다 (모르면 청크 전송)
|
||||
size: download.length,
|
||||
};
|
||||
}
|
||||
|
||||
readAttachmentBytes(
|
||||
user: string,
|
||||
attachmentId: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null> {
|
||||
return this.jmap.downloadBytes(user, attachmentId, maxBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 확인 + 목록 조회 + 요약 조회를 한 요청으로 보낸다.
|
||||
* 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
|
||||
* (Stalwart 는 필터의 id 형식이 틀리면 요청 전체를 notRequest 로 거부한다).
|
||||
*/
|
||||
private queryMessages(
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
start: { anchor: string } | { position: number },
|
||||
): Promise<JmapInvocation[]> {
|
||||
const filter = query.text
|
||||
? { inMailbox: query.mailboxId, text: query.text }
|
||||
: { inMailbox: query.mailboxId };
|
||||
|
||||
return this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: [query.mailboxId], properties: ['id'] },
|
||||
'mb',
|
||||
],
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
...('anchor' in start
|
||||
? { anchor: start.anchor, anchorOffset: 1 }
|
||||
: { position: start.position }),
|
||||
limit: query.limit,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 합계 크기 확인 — 업로드한 파일과 전달하는 원본 첨부 모두 Blob/get 으로 잰다.
|
||||
* 한도는 MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진의 메일 한 통 첨부 한도 중 작은 값이다.
|
||||
* 같은 첨부를 두 번 붙이면 두 번 센다 (메일에 두 번 들어간다).
|
||||
*/
|
||||
private async assertAttachmentsFit(
|
||||
user: string,
|
||||
attachments: readonly OutgoingAttachment[],
|
||||
session: JmapSession,
|
||||
): Promise<void> {
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
const ids = [...new Set(attachments.map((attachment) => attachment.id))];
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
['Blob/get', { accountId, ids, properties: ['size'] }, 'b'],
|
||||
],
|
||||
// 엔진이 해석하지 못하는 id 형식도 "찾을 수 없는 첨부"다
|
||||
{ capabilities: [BLOB_CAPABILITY], invalidRequestCode: 'MAIL_004' },
|
||||
);
|
||||
const { list = [], notFound = [] } = expectResult<JmapGetResult<JmapBlob>>(
|
||||
responses,
|
||||
'b',
|
||||
'Blob/get',
|
||||
);
|
||||
|
||||
const sizes = new Map(list.map((blob) => [blob.id, blob.size]));
|
||||
if (notFound.length > 0 || ids.some((id) => !sizes.has(id))) {
|
||||
throw new BusinessException('MAIL_004', {
|
||||
context: {
|
||||
reason: 'attachment-blob-not-found',
|
||||
missing: notFound.length || 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const totalBytes = attachments.reduce(
|
||||
(sum, attachment) => sum + (sizes.get(attachment.id) ?? 0),
|
||||
0,
|
||||
);
|
||||
const limitBytes = this.attachmentsTotalLimit(session);
|
||||
if (totalBytes > limitBytes) {
|
||||
throw new BusinessException('FILE_002', {
|
||||
context: {
|
||||
reason: 'attachments-total-too-large',
|
||||
totalBytes,
|
||||
limitBytes,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 합계 한도 — 발송 검사와 `getAttachmentsTotalLimit`(mail-quota)이 **이것 하나**를 쓴다.
|
||||
* 설정 한도와 엔진 세션의 `maxSizeAttachmentsPerEmail` 중 작은 값 (attachment.policy.ts).
|
||||
*/
|
||||
private attachmentsTotalLimit(session: JmapSession): number {
|
||||
return resolveAttachmentsTotalLimit(
|
||||
this.settings.attachmentTotalMaxBytes,
|
||||
session.maxAttachmentsBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/** 이 메일로 만든 제출(EmailSubmission)이 있는가 */
|
||||
private async wasSubmitted(user: string, emailId: string): Promise<boolean> {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'EmailSubmission/query',
|
||||
{ accountId, filter: { emailIds: [emailId] } },
|
||||
's',
|
||||
],
|
||||
]);
|
||||
const { ids = [] } = expectResult<JmapQueryResult>(
|
||||
responses,
|
||||
's',
|
||||
'EmailSubmission/query',
|
||||
);
|
||||
return ids.length > 0;
|
||||
}
|
||||
|
||||
/** 계정의 모든 메일 크기를 더한다 — 엔진이 limit 을 줄여 돌려줘도 실제로 받은 개수만큼 전진한다 */
|
||||
private async sumEmailSizes(user: string): Promise<number> {
|
||||
let position = 0;
|
||||
let usedBytes = 0;
|
||||
|
||||
while (position < USAGE_SCAN_MAX_EMAILS) {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
position,
|
||||
limit: USAGE_SCAN_PAGE_SIZE,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
|
||||
properties: ['size'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
|
||||
const { ids = [], total } = expectResult<JmapQueryResult>(
|
||||
responses,
|
||||
'q',
|
||||
'Email/query',
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
usedBytes += list.reduce((sum, email) => sum + (email.size ?? 0), 0);
|
||||
position += ids.length;
|
||||
|
||||
const exhausted =
|
||||
ids.length === 0 || (typeof total === 'number' && position >= total);
|
||||
if (exhausted) return usedBytes;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 메일이 ${USAGE_SCAN_MAX_EMAILS}통을 넘어 사용량을 끝까지 세지 않았습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
return usedBytes;
|
||||
}
|
||||
|
||||
/** Email/get 응답에서 메일 하나를 꺼낸다. 없으면 RES_001 */
|
||||
private requireEmail(responses: JmapInvocation[], callId: string): JmapEmail {
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
callId,
|
||||
'Email/get',
|
||||
);
|
||||
// 엔진이 id 를 정규화해 돌려줄 수 있으므로(대소문자 등) 첫 항목을 쓴다 — 계정 안의 메일만 온다
|
||||
const [email] = list;
|
||||
if (!email) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'email-not-found' },
|
||||
});
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/** 답장할 원 메일을 찾지 못하면 스레드 헤더 없이 보낸다 (지워진 메일에 답장하는 경우) */
|
||||
private threadHeaders(
|
||||
prepared: JmapInvocation[],
|
||||
): { inReplyTo: string[]; references: string[] } | null {
|
||||
const original = findResult(prepared, 'r', 'Email/get');
|
||||
const email = original.ok
|
||||
? (original.args as JmapGetResult<JmapEmail>).list?.[0]
|
||||
: undefined;
|
||||
|
||||
if (!email) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 답장할 원 메일을 찾지 못해 스레드 헤더 없이 보냅니다.`,
|
||||
);
|
||||
}
|
||||
return buildThreadHeaders(email);
|
||||
}
|
||||
|
||||
private async discardDraft(user: string, draftId: string): Promise<void> {
|
||||
try {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Email/set', { accountId, destroy: [draftId] }, 'x'],
|
||||
]);
|
||||
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
|
||||
if (firstSetError(set.notDestroyed)) throw new Error('notDestroyed');
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 발송은 끝났지만 보낸편지함 이동이 실패한 경우 — 사용자 응답은 성공으로 두고 경고만 남긴다 */
|
||||
private warnIfNotMovedToSent(
|
||||
responses: JmapInvocation[],
|
||||
user: string,
|
||||
): void {
|
||||
const moved = responses.find(
|
||||
([name, , id]) => name === 'Email/set' && id === 's',
|
||||
);
|
||||
const notUpdated = (moved?.[1] as JmapSetResult | undefined)?.notUpdated;
|
||||
if (notUpdated && Object.keys(notUpdated).length > 0) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 발송한 메일을 보낸편지함으로 옮기지 못했습니다: user=${maskEmail(user)}, jmapType=${firstSetError(notUpdated)?.type ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
import type { Cache } from 'cache-manager';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { runWithRequestContext } from '@/shared/logger/request-context';
|
||||
import {
|
||||
JMAP_SEND_TIMEOUT_MS,
|
||||
JmapClient,
|
||||
basicAuthorization,
|
||||
classifyManagementProbe,
|
||||
expandTemplate,
|
||||
toRelativeReference,
|
||||
} from './jmap.client';
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set(['admin@arasystem.test']),
|
||||
};
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
|
||||
/** 검증된 Stalwart 세션 응답 형태 — URL 은 공개 주소(MAIL_PUBLIC_URL) 기준 절대 주소다 */
|
||||
const sessionBody = {
|
||||
username: ARA,
|
||||
apiUrl: 'http://localhost:8180/jmap/',
|
||||
downloadUrl:
|
||||
'http://localhost:8180/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
uploadUrl: 'http://localhost:8180/jmap/upload/{accountId}/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'd' },
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Map 기반 가짜 캐시 — cache-manager 의 get/set/del 만 흉내 낸다 */
|
||||
function fakeCache(): Cache {
|
||||
const store = new Map<string, unknown>();
|
||||
return {
|
||||
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
||||
set: jest.fn((key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve(value);
|
||||
}),
|
||||
del: jest.fn((key: string) => Promise.resolve(store.delete(key))),
|
||||
} as unknown as Cache;
|
||||
}
|
||||
|
||||
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decodeBasic(header: string): string {
|
||||
return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
describe('JmapClient', () => {
|
||||
let fetchMock: jest.SpyInstance;
|
||||
let cache: Cache;
|
||||
let client: JmapClient;
|
||||
|
||||
/** n 번째 fetch 호출의 URL·헤더 */
|
||||
const requestAt = (index: number) => {
|
||||
const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
|
||||
return {
|
||||
url,
|
||||
init,
|
||||
authorization: (init.headers as Record<string, string>).authorization,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch');
|
||||
cache = fakeCache();
|
||||
client = new JmapClient(settings, cache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('직원 자격 확인 (verifyCredentials)', () => {
|
||||
it('직원 주소:비밀번호 Basic 인증으로 내부 주소의 세션을 조회한다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ ...sessionBody, username: 'Ara.Kim@AraSystem.test' }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.verifyCredentials(ARA, 'pw:with:colon'),
|
||||
).resolves.toBe(ARA);
|
||||
|
||||
const { url, authorization } = requestAt(0);
|
||||
expect(url).toBe('http://mail:8080/jmap/session');
|
||||
expect(decodeBasic(authorization)).toBe(`${ARA}:pw:with:colon`);
|
||||
});
|
||||
|
||||
it('401·403 이면 null (틀린 자격 증명)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 403));
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('엔진 5xx·네트워크 오류는 MAIL_001, 429 는 REQ_001', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 502));
|
||||
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 429));
|
||||
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('응답이 없으면 제한 시간 뒤 MAIL_001 (timeout) 이다', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = client.verifyCredentials(ARA, 'pw');
|
||||
const settled = pending.catch((error: unknown) => error);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const error = (await settled) as BusinessException;
|
||||
expect(error.code).toBe('MAIL_001');
|
||||
expect(error.context).toMatchObject({ reason: 'timeout' });
|
||||
});
|
||||
|
||||
it('메일 설정이 없으면 엔진에 가지 않고 MAIL_001 이다', async () => {
|
||||
const unconfigured = new JmapClient(
|
||||
{ ...settings, jmapBaseUrl: null },
|
||||
cache,
|
||||
);
|
||||
|
||||
expect(await codeOf(unconfigured.verifyCredentials(ARA, 'pw'))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('대리 접근 세션 (getSession)', () => {
|
||||
it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
|
||||
const session = await client.getSession(ARA);
|
||||
|
||||
expect(decodeBasic(requestAt(0).authorization)).toBe(
|
||||
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
|
||||
);
|
||||
expect(session).toEqual({
|
||||
username: ARA,
|
||||
accountId: 'd',
|
||||
apiPath: '/jmap/',
|
||||
downloadPath:
|
||||
'/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
uploadPath: '/jmap/upload/{accountId}/',
|
||||
// 계정 기능을 알려 주지 않는 응답 — 엔진 한도를 모르는 것으로 둔다
|
||||
maxAttachmentsBytes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('계정의 메일 기능이 알려 주는 첨부 합계 한도(maxSizeAttachmentsPerEmail)를 읽어 둔다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
...sessionBody,
|
||||
accounts: {
|
||||
d: {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {
|
||||
maxSizeAttachmentsPerEmail: 50_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const session = await client.getSession(ARA);
|
||||
|
||||
expect(session.maxAttachmentsBytes).toBe(50_000_000);
|
||||
});
|
||||
|
||||
it('직원별로 캐시하고, fresh 면 다시 조회한다', async () => {
|
||||
fetchMock.mockImplementation(() => Promise.resolve(json(sessionBody)));
|
||||
|
||||
await client.getSession(ARA);
|
||||
await client.getSession(ARA);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await client.getSession(ARA, { fresh: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('대리 접근 401 이고 게이트웨이 자체는 정상이면 계정이 사라진 것 → AUTH_001', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({}, 401))
|
||||
// 게이트웨이 자신으로 로그인 확인
|
||||
.mockResolvedValueOnce(json({}, 200));
|
||||
|
||||
expect(await codeOf(client.getSession(ARA))).toBe('AUTH_001');
|
||||
expect(decodeBasic(requestAt(1).authorization)).toBe(
|
||||
'mail-gateway@arasystem.test:gateway-secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('게이트웨이 자격 증명 자체가 거부되면 설정 문제 → MAIL_001 (전 직원 로그아웃을 막는다) — 오류 로그에 코드·추적 ID', async () => {
|
||||
const errorLog = jest
|
||||
.spyOn(
|
||||
Reflect.get(client, 'logger') as { error: (message: string) => void },
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({}, 401))
|
||||
.mockResolvedValueOnce(json({}, 401));
|
||||
|
||||
const code = await runWithRequestContext({ requestId: 'trace-gw' }, () =>
|
||||
codeOf(client.getSession(ARA)),
|
||||
);
|
||||
|
||||
expect(code).toBe('MAIL_001');
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[MAIL_001\]\[trace-gw\] /),
|
||||
);
|
||||
});
|
||||
|
||||
it('대리 접근 403(게이트웨이에 대리 권한 없음)은 계정 문제가 아니라 설정 문제 → MAIL_001 + error 로그 (세션을 폐기하게 두지 않는다)', async () => {
|
||||
const errorLog = jest
|
||||
.spyOn(
|
||||
Reflect.get(client, 'logger') as { error: (message: string) => void },
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
// Stalwart 가 실제로 주는 본문 (개발 엔진에서 확인)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Forbidden',
|
||||
detail:
|
||||
'You do not have enough permissions to access this resource.',
|
||||
status: 403,
|
||||
},
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
const code = await runWithRequestContext({ requestId: 'trace-403' }, () =>
|
||||
codeOf(client.getSession(ARA)),
|
||||
);
|
||||
|
||||
expect(code).toBe('MAIL_001');
|
||||
// 게이트웨이 확인을 하지 않는다 — 403 은 인증이 이미 된 상태다
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^\[MAIL_001\]\[trace-403\] .*대리 접근 권한이 없습니다/,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('막는 계정(메일 관리자)은 엔진에 대리 접근을 요청하지 않고 AUTH_001 (대소문자 무관)', async () => {
|
||||
for (const user of ['admin@arasystem.test', ' Admin@AraSystem.TEST']) {
|
||||
expect(await codeOf(client.getSession(user, { fresh: true }))).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('로컬 이름·별칭으로 들어와도 엔진이 알려 준 정식 주소가 막는 계정이면 AUTH_001 — 캐시하지 않는다', async () => {
|
||||
// Stalwart 는 대리 접근 대상 "admin" 을 admin@<도메인> 으로 풀어 준다 (개발 엔진에서 확인)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ ...sessionBody, username: 'admin@arasystem.test' }),
|
||||
);
|
||||
|
||||
expect(await codeOf(client.getSession('admin'))).toBe('AUTH_001');
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('메일 계정이 없는 세션은 MAIL_001', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ ...sessionBody, primaryAccounts: {} }),
|
||||
);
|
||||
|
||||
expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('관리 권한 확인 (assertNoManagementAccess)', () => {
|
||||
const OPS = 'it.ops@arasystem.test';
|
||||
const probeAnswer = (invocation: unknown[]) =>
|
||||
json({ methodResponses: [invocation], sessionState: 's' });
|
||||
let errorLog: jest.SpyInstance;
|
||||
let warnLog: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const logger = Reflect.get(client, 'logger') as {
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
errorLog = jest
|
||||
.spyOn(logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
warnLog = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('관리 API 를 데이터 없이(ids: []) 불러 성공하면 관리 권한이 있는 계정 → AUTH_001, 캐시한 세션을 지운다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({ ...sessionBody, username: OPS }))
|
||||
.mockResolvedValueOnce(
|
||||
probeAnswer(['x:Account/get', { list: [], notFound: [] }, 'p']),
|
||||
);
|
||||
|
||||
const failure = await runWithRequestContext(
|
||||
{ requestId: 'trace-admin' },
|
||||
() =>
|
||||
client.assertNoManagementAccess(OPS).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error as BusinessException,
|
||||
),
|
||||
);
|
||||
|
||||
expect(failure?.code).toBe('AUTH_001');
|
||||
expect(failure?.context).toMatchObject({ reason: 'management-account' });
|
||||
expect(cache.del).toHaveBeenCalledWith(`mail:jmap-session:${OPS}`);
|
||||
expect(warnLog).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[AUTH_001\]\[trace-admin\] /),
|
||||
);
|
||||
|
||||
const { url, init, authorization } = requestAt(1);
|
||||
expect(url).toBe('http://mail:8080/jmap/');
|
||||
expect(decodeBasic(authorization)).toBe(
|
||||
`${OPS}%mail-gateway@arasystem.test:gateway-secret`,
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
using: ['urn:ietf:params:jmap:core'],
|
||||
methodCalls: [
|
||||
[
|
||||
'x:Account/get',
|
||||
{ accountId: 'd', ids: [], properties: ['name'] },
|
||||
'p',
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('forbidden 이면 관리 권한이 없는 직원 — 그대로 통과한다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(sessionBody))
|
||||
.mockResolvedValueOnce(
|
||||
probeAnswer(['error', { type: 'forbidden' }, 'p']),
|
||||
);
|
||||
|
||||
await expect(client.assertNoManagementAccess(ARA)).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('해석할 수 없는 응답(unknownMethod·요청 형식 거부 400·빈 응답)은 막지 않는다 — error 로그는 한 번만', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(sessionBody))
|
||||
.mockResolvedValueOnce(
|
||||
probeAnswer(['error', { type: 'unknownMethod' }, 'p']),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({ type: 'urn:ietf:params:jmap:error:notRequest' }, 400),
|
||||
)
|
||||
.mockResolvedValueOnce(json({ methodResponses: [] }));
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await expect(client.assertNoManagementAccess(ARA)).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[MAIL_001\].*error\/unknownMethod/),
|
||||
);
|
||||
});
|
||||
|
||||
it('엔진 장애는 다른 호출과 같다 — 5xx 는 MAIL_001, 429 는 REQ_001', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(sessionBody))
|
||||
.mockResolvedValueOnce(json({}, 502))
|
||||
.mockResolvedValueOnce(json({}, 429));
|
||||
|
||||
expect(await codeOf(client.assertNoManagementAccess(ARA))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(await codeOf(client.assertNoManagementAccess(ARA))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('막는 계정은 확인 호출도 하지 않고 AUTH_001', async () => {
|
||||
expect(
|
||||
await codeOf(client.assertNoManagementAccess('admin@arasystem.test')),
|
||||
).toBe('AUTH_001');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('메서드 호출 (call)', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
});
|
||||
|
||||
it('세션의 accountId 로 호출을 만들고 내부 주소의 API 경로로 보낸다', async () => {
|
||||
const responses = [['Mailbox/get', { list: [] }, 'm']];
|
||||
fetchMock.mockResolvedValueOnce(json({ methodResponses: responses }));
|
||||
|
||||
const result = await client.call(ARA, (accountId) => [
|
||||
['Mailbox/get', { accountId }, 'm'],
|
||||
]);
|
||||
|
||||
expect(result).toEqual(responses);
|
||||
const { url, init } = requestAt(1);
|
||||
expect(url).toBe('http://mail:8080/jmap/');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
using: [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:submission',
|
||||
],
|
||||
methodCalls: [['Mailbox/get', { accountId: 'd' }, 'm']],
|
||||
});
|
||||
});
|
||||
|
||||
it('호출별 추가 기능은 그 요청의 using 에만 붙인다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ methodResponses: [] }));
|
||||
|
||||
await client.call(ARA, () => [], {
|
||||
capabilities: ['urn:ietf:params:jmap:quota'],
|
||||
});
|
||||
|
||||
const { init } = requestAt(1);
|
||||
expect(
|
||||
(JSON.parse(init.body as string) as { using: string[] }).using,
|
||||
).toEqual([
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:submission',
|
||||
'urn:ietf:params:jmap:quota',
|
||||
]);
|
||||
});
|
||||
|
||||
it('제한 시간은 호출별로 늘릴 수 있다 — 발송처럼 큰 요청 (기본 10초)', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!init.body) {
|
||||
resolve(json(sessionBody));
|
||||
return;
|
||||
}
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const pending = client
|
||||
.call(ARA, () => [], { timeoutMs: JMAP_SEND_TIMEOUT_MS })
|
||||
.catch((error: unknown) => error)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(JMAP_SEND_TIMEOUT_MS - 10_000);
|
||||
const error = (await pending) as BusinessException;
|
||||
expect(error.code).toBe('MAIL_001');
|
||||
expect(error.context).toMatchObject({ reason: 'timeout' });
|
||||
});
|
||||
|
||||
it('요청 자체가 거부되면(notRequest) 지정한 코드, 지정이 없으면 MAIL_001', async () => {
|
||||
const notRequest = {
|
||||
type: 'urn:ietf:params:jmap:error:notRequest',
|
||||
status: 400,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(json(notRequest, 400));
|
||||
expect(
|
||||
await codeOf(
|
||||
client.call(ARA, () => [], { invalidRequestCode: 'VAL_001' }),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json(notRequest, 400));
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('엔진 5xx 는 MAIL_001, 429 는 REQ_001, 형식이 깨진 응답은 MAIL_001', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 503));
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 429));
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('REQ_001');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({ unexpected: true }));
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('호출 중 대리 접근이 거부되면 캐시한 세션을 지우고 AUTH_001', async () => {
|
||||
await client.getSession(ARA);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({}, 401))
|
||||
.mockResolvedValueOnce(json({}, 200));
|
||||
|
||||
expect(await codeOf(client.call(ARA, () => []))).toBe('AUTH_001');
|
||||
expect(cache.del).toHaveBeenCalledWith(`mail:jmap-session:${ARA}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('첨부 업로드·다운로드', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
});
|
||||
|
||||
it('업로드 경로의 accountId 를 채우고 본문(Blob)을 그대로 넘긴다 — 파일 Blob 은 fetch 가 조금씩 읽는다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ accountId: 'd', blobId: 'blob1', type: 'text/plain', size: 3 }),
|
||||
);
|
||||
const content = new Blob(['abc'], { type: 'text/plain' });
|
||||
|
||||
const result = await client.upload(ARA, content, 'text/plain');
|
||||
|
||||
expect(result).toEqual({ blobId: 'blob1', type: 'text/plain', size: 3 });
|
||||
const { url, init } = requestAt(1);
|
||||
expect(url).toBe('http://mail:8080/jmap/upload/d/');
|
||||
expect(init.body).toBe(content);
|
||||
expect((init.headers as Record<string, string>)['content-type']).toBe(
|
||||
'text/plain',
|
||||
);
|
||||
});
|
||||
|
||||
it('엔진이 크기를 알려 주지 않으면 보낸 Blob 의 크기를 쓴다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ blobId: 'blob1' }));
|
||||
|
||||
const result = await client.upload(
|
||||
ARA,
|
||||
new Blob(['abcd']),
|
||||
'application/pdf',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
blobId: 'blob1',
|
||||
type: 'application/pdf',
|
||||
size: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진의 업로드 한도 초과는 FILE_002', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 413));
|
||||
|
||||
expect(
|
||||
await codeOf(client.upload(ARA, new Blob(['abc']), 'text/plain')),
|
||||
).toBe('FILE_002');
|
||||
});
|
||||
|
||||
it('다운로드 경로의 변수를 퍼센트 인코딩해 채우고 스트림으로 돌려준다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('파일 내용', {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(Buffer.byteLength('파일 내용')) },
|
||||
}),
|
||||
);
|
||||
|
||||
const download = await client.download(
|
||||
ARA,
|
||||
'blob1',
|
||||
'체크 리스트.txt',
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
expect(requestAt(1).url).toBe(
|
||||
`http://mail:8080/jmap/download/d/blob1/${encodeURIComponent('체크 리스트.txt')}?accept=application%2Foctet-stream`,
|
||||
);
|
||||
expect(download.length).toBe(Buffer.byteLength('파일 내용'));
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of download.stream) chunks.push(chunk as Buffer);
|
||||
expect(Buffer.concat(chunks).toString('utf8')).toBe('파일 내용');
|
||||
});
|
||||
|
||||
it('압축을 요청하지 않고, 압축되어 오면 길이를 모르는 것으로 둔다 (잘린 다운로드 방지)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('abc', {
|
||||
status: 200,
|
||||
headers: { 'content-length': '2', 'content-encoding': 'gzip' },
|
||||
}),
|
||||
);
|
||||
|
||||
const download = await client.download(ARA, 'b', 'a.txt', 'text/plain');
|
||||
|
||||
expect(
|
||||
(requestAt(1).init.headers as Record<string, string>)[
|
||||
'accept-encoding'
|
||||
],
|
||||
).toBe('identity');
|
||||
expect(download.length).toBeNull();
|
||||
download.stream.destroy();
|
||||
});
|
||||
|
||||
it('없는 blob 은 RES_001', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 404));
|
||||
|
||||
expect(
|
||||
await codeOf(
|
||||
client.download(ARA, 'nope', 'a', 'application/octet-stream'),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('작은 부분 읽기는 한도를 넘으면 null 이다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(Buffer.alloc(2048), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '2048' },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.downloadBytes(ARA, 'blob1', 1024),
|
||||
).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(Buffer.from([1, 2, 3]), { status: 200 }),
|
||||
);
|
||||
await expect(client.downloadBytes(ARA, 'blob1', 1024)).resolves.toEqual(
|
||||
Buffer.from([1, 2, 3]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAP URL 유틸', () => {
|
||||
it('공개 주소의 스킴·호스트를 떼고 경로·쿼리·템플릿 변수를 그대로 둔다', () => {
|
||||
expect(toRelativeReference('http://localhost:8180/jmap/')).toBe('/jmap/');
|
||||
expect(
|
||||
toRelativeReference(
|
||||
'https://mx1.arasystem.it.kr/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
),
|
||||
).toBe('/jmap/download/{accountId}/{blobId}/{name}?accept={type}');
|
||||
// 상대 경로로 오는 경우
|
||||
expect(toRelativeReference('/jmap/')).toBe('/jmap/');
|
||||
expect(toRelativeReference('jmap/')).toBe('/jmap/');
|
||||
});
|
||||
|
||||
it('템플릿에 없는 변수는 그대로 두고, 프로토타입 속성 이름에 속지 않는다', () => {
|
||||
expect(
|
||||
expandTemplate('/x/{accountId}/{constructor}', { accountId: 'd' }),
|
||||
).toBe('/x/d/{constructor}');
|
||||
});
|
||||
|
||||
it('Basic 인증 값은 UTF-8 로 인코딩한다', () => {
|
||||
expect(decodeBasic(basicAuthorization('a@b.c', '비밀'))).toBe('a@b.c:비밀');
|
||||
});
|
||||
});
|
||||
|
||||
describe('관리 권한 확인 응답 해석 (classifyManagementProbe)', () => {
|
||||
it('성공은 관리 권한 있음, forbidden 은 없음', () => {
|
||||
expect(
|
||||
classifyManagementProbe(['x:Account/get', { list: [] }, 'p']),
|
||||
).toEqual({ kind: 'management' });
|
||||
expect(
|
||||
classifyManagementProbe(['error', { type: 'forbidden' }, 'p']),
|
||||
).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('그 밖은 판단하지 않는다 (로그용 요약만)', () => {
|
||||
expect(
|
||||
classifyManagementProbe(['error', { type: 'unknownMethod' }, 'p']),
|
||||
).toEqual({ kind: 'unclear', detail: 'error/unknownMethod' });
|
||||
expect(classifyManagementProbe(['Other/get', {}, 'p'])).toEqual({
|
||||
kind: 'unclear',
|
||||
detail: 'Other/get',
|
||||
});
|
||||
expect(classifyManagementProbe(undefined)).toEqual({
|
||||
kind: 'unclear',
|
||||
detail: 'undefined',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,764 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { Readable } from 'stream';
|
||||
import type { ReadableStream as NodeReadableStream } from 'stream/web';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { ErrorCode } from '@/common/errors/error-codes';
|
||||
import {
|
||||
MAIL_SETTINGS,
|
||||
isMailEngineConfigured,
|
||||
type ConfiguredMailSettings,
|
||||
type MailSettings,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type {
|
||||
JmapInvocation,
|
||||
JmapProblem,
|
||||
JmapResponseBody,
|
||||
JmapSessionResource,
|
||||
JmapUploadResult,
|
||||
} from './jmap.types';
|
||||
|
||||
/** 사용하는 JMAP 기능 — 메일 읽기·쓰기와 발송 */
|
||||
export const JMAP_USING = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:submission',
|
||||
] as const;
|
||||
|
||||
const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
|
||||
const CORE_CAPABILITY = 'urn:ietf:params:jmap:core';
|
||||
const SESSION_PATH = '/jmap/session';
|
||||
|
||||
/**
|
||||
* 관리 권한 확인에 쓰는 관리 API 메서드 (Stalwart 의 `x:` 관리 객체) — `ids: []` 로 불러 **데이터는 읽지 않는다**.
|
||||
* 관리자 세션은 성공하고, 직원 세션은 `forbidden` 이다 (Stalwart 0.16 개발 엔진에서 확인).
|
||||
*/
|
||||
const MANAGEMENT_PROBE_METHOD = 'x:Account/get';
|
||||
|
||||
/** 관리 권한 확인 결과 — 관리 권한 있음 · 없음 · 해석할 수 없음(엔진 API 변경 등, detail 은 로그용 요약) */
|
||||
export type ManagementProbeResult =
|
||||
| { readonly kind: 'management' }
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'unclear'; readonly detail: string };
|
||||
|
||||
/**
|
||||
* 관리 권한 확인 호출의 메서드 응답을 해석한다 (순수 함수).
|
||||
* 성공 = 관리 권한 있음, `forbidden` = 없음. 그 밖(unknownMethod·다른 오류·빈 응답)은 판단하지 않는다.
|
||||
*/
|
||||
export function classifyManagementProbe(
|
||||
invocation: JmapInvocation | undefined,
|
||||
): ManagementProbeResult {
|
||||
const [name, args] = invocation ?? [];
|
||||
if (name === MANAGEMENT_PROBE_METHOD) return { kind: 'management' };
|
||||
|
||||
if (name === 'error') {
|
||||
const type = (args as JmapProblem | undefined)?.type;
|
||||
return type === 'forbidden'
|
||||
? { kind: 'none' }
|
||||
: { kind: 'unclear', detail: `error/${String(type)}` };
|
||||
}
|
||||
return { kind: 'unclear', detail: String(name) };
|
||||
}
|
||||
|
||||
/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지 */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* 발송(초안 생성 + 제출) 제한 시간. 엔진이 첨부(최대 합계 25MB)를 붙여 메시지를 만드는 시간을 넉넉히 주되,
|
||||
* 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 — 백엔드가 먼저 MAIL_001 로 답해야 사용자가 같은 키로 다시 보낼 수 있다.
|
||||
*/
|
||||
export const JMAP_SEND_TIMEOUT_MS = 40_000;
|
||||
/** 첨부 업로드 제한 시간 — 임시 파일에서 엔진으로 흘려보낸다 (내부망 기준 20MB 에 충분한 여유) */
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
/** 다운로드는 응답 헤더까지만 제한한다 — 본문은 클라이언트로 흘려보내는 중이라 시간을 정할 수 없다 */
|
||||
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
|
||||
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
|
||||
const SMALL_DOWNLOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */
|
||||
const SESSION_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_BAD_REQUEST = 400;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
const HTTP_PAYLOAD_TOO_LARGE = 413;
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
|
||||
/** 직원 메일함에 접근하는 데 필요한 세션 정보 (캐시에 저장되므로 평범한 객체로 둔다) */
|
||||
export interface JmapSession {
|
||||
/** 엔진이 아는 정식 계정 주소 */
|
||||
readonly username: string;
|
||||
readonly accountId: string;
|
||||
/** MAIL_JMAP_URL 에 붙일 경로 — 세션 응답의 공개 주소에서 경로만 떼어 낸 것 */
|
||||
readonly apiPath: string;
|
||||
readonly downloadPath: string;
|
||||
readonly uploadPath: string;
|
||||
/** 엔진의 메일 한 통 첨부 합계 한도(바이트) — 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다) */
|
||||
readonly maxAttachmentsBytes?: number | null;
|
||||
}
|
||||
|
||||
export interface JmapDownload {
|
||||
readonly stream: Readable;
|
||||
readonly length: number | null;
|
||||
}
|
||||
|
||||
export interface JmapCallOptions {
|
||||
/**
|
||||
* 엔진이 요청 자체를 해석하지 못했을 때(HTTP 400 `notRequest`) 쓸 코드.
|
||||
* Stalwart 는 형식이 맞지 않는 id 가 필터에 들어오면 요청 전체를 거부한다 —
|
||||
* 사용자가 보낸 id 가 들어가는 호출은 MAIL_001(엔진 장애) 대신 이 코드로 응답한다.
|
||||
*/
|
||||
readonly invalidRequestCode?: ErrorCode;
|
||||
/**
|
||||
* 이 호출에만 추가로 선언할 기능 (예: 쿼터 `urn:ietf:params:jmap:quota`).
|
||||
* 모든 요청에 넣지 않는 이유: 엔진이 모르는 기능이 `using` 에 있으면 요청 전체가 거부된다.
|
||||
*/
|
||||
readonly capabilities?: readonly string[];
|
||||
/** 제한 시간(ms) — 발송처럼 큰 요청만 늘린다 (기본 10초) */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** 'http://localhost:8180/jmap/' → '/jmap/' (스킴·호스트를 떼고 경로·쿼리만 남긴다) */
|
||||
export function toRelativeReference(url: string): string {
|
||||
const withoutOrigin = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, '');
|
||||
return withoutOrigin.startsWith('/') ? withoutOrigin : `/${withoutOrigin}`;
|
||||
}
|
||||
|
||||
/** URL 템플릿의 `{변수}` 를 퍼센트 인코딩한 값으로 채운다 (RFC 6570 단순 확장) */
|
||||
export function expandTemplate(
|
||||
template: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(values, name)
|
||||
? encodeURIComponent(values[name])
|
||||
: placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
export function basicAuthorization(user: string, password: string): string {
|
||||
return `Basic ${Buffer.from(`${user}:${password}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function sessionCacheKey(user: string): string {
|
||||
return `mail:jmap-session:${user}`;
|
||||
}
|
||||
|
||||
/** 연결이 반환되도록 읽지 않을 본문을 버린다 */
|
||||
async function discard(response: Response): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// 이미 닫힌 본문 — 무시한다
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
|
||||
const { cause } = error as { cause?: unknown };
|
||||
if (cause instanceof Error) {
|
||||
const code = (cause as NodeJS.ErrnoException).code ?? cause.name;
|
||||
return `${error.message} (${code}: ${cause.message})`;
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
function engineFailure(context: Record<string, unknown>): BusinessException {
|
||||
return new BusinessException('MAIL_001', { context });
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, label: string): Promise<T> {
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
throw engineFailure({ reason: 'malformed-response', call: label });
|
||||
}
|
||||
}
|
||||
|
||||
/** 실패 응답의 problem details 를 읽는다 (JSON 이 아니면 null) */
|
||||
async function readProblem(response: Response): Promise<JmapProblem | null> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
return typeof body === 'object' && body !== null ? body : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정의 메일 기능이 알려 주는 첨부 합계 한도 (`maxSizeAttachmentsPerEmail`) — 없거나 이상하면 null */
|
||||
function readAttachmentsLimit(
|
||||
resource: JmapSessionResource,
|
||||
accountId: string,
|
||||
): number | null {
|
||||
const limit =
|
||||
resource.accounts?.[accountId]?.accountCapabilities?.[MAIL_CAPABILITY]
|
||||
?.maxSizeAttachmentsPerEmail;
|
||||
return typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0
|
||||
? limit
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseContentLength(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
const length = Number(value);
|
||||
return Number.isSafeInteger(length) && length >= 0 ? length : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart 내부 JMAP 에 붙는 얇은 클라이언트 (Node 내장 fetch).
|
||||
*
|
||||
* - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401)
|
||||
* - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>`
|
||||
* - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md)
|
||||
*
|
||||
* 인증 헤더·비밀번호는 어떤 경우에도 로그에 남기지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapClient {
|
||||
private readonly logger = new Logger(JmapClient.name);
|
||||
/** 관리 권한 확인 응답을 해석하지 못했다는 error 로그를 이미 남겼는가 (엔진 API 가 바뀐 경우 — 한 번만 알린다) */
|
||||
private managementProbeUnclearReported = false;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 직원 자격 증명을 확인한다.
|
||||
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null. 엔진 장애는 MAIL_001.
|
||||
*/
|
||||
async verifyCredentials(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string | null> {
|
||||
return this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: basicAuthorization(email, password) } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'verify',
|
||||
async (response) => {
|
||||
if (
|
||||
response.status === HTTP_UNAUTHORIZED ||
|
||||
response.status === HTTP_FORBIDDEN
|
||||
) {
|
||||
await discard(response);
|
||||
return null;
|
||||
}
|
||||
await this.assertOk(response, 'verify');
|
||||
|
||||
const session = await readJson<JmapSessionResource>(response, 'verify');
|
||||
return session.username?.trim().toLowerCase() || email;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다 */
|
||||
async getSession(
|
||||
user: string,
|
||||
options: { fresh?: boolean } = {},
|
||||
): Promise<JmapSession> {
|
||||
// 막는 계정(관리자 등)은 엔진에 대리 접근을 요청하지도 않는다
|
||||
this.assertNotBlocked(user);
|
||||
const key = sessionCacheKey(user);
|
||||
if (!options.fresh) {
|
||||
const cached = await this.cache.get<JmapSession>(key);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const session = await this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: this.impersonation(user) } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'session',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
await this.assertOk(response, 'session');
|
||||
return this.toSession(
|
||||
await readJson<JmapSessionResource>(response, 'session'),
|
||||
user,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 엔진이 알려 준 정식 주소로 한 번 더 — 로컬 이름(admin)이나 별칭으로 들어와도 막는 계정이면 캐시하지 않는다
|
||||
this.assertNotBlocked(session.username);
|
||||
await this.cache.set(key, session, SESSION_CACHE_TTL_MS);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 계정에 메일 엔진의 **관리 권한**이 있으면 AUTH_001 — 웹메일로 관리자 계정을 다루지 않는다.
|
||||
* 게이트웨이는 관리자도 대리 접근할 수 있고 그 세션은 관리 권한을 가지므로(확인함), 정적 목록(blockedAccounts)에 없는
|
||||
* 관리자도 로그인·리프레시 때 걸러 낸다. 데이터를 읽지 않는 관리 호출 한 번으로 판별한다 (MANAGEMENT_PROBE_METHOD).
|
||||
*
|
||||
* 해석할 수 없는 응답(엔진 API 변경으로 unknownMethod·요청 형식 거부(400) 등)은 막지 않는다 — 전 직원 로그인을 막지 않기 위해서다.
|
||||
* 대신 error 로그를 한 번 남긴다 (관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>` 은 정적 목록이 계속 막는다).
|
||||
* 엔진 장애·한도는 다른 호출과 같다 (MAIL_001 · REQ_001).
|
||||
*/
|
||||
async assertNoManagementAccess(user: string): Promise<void> {
|
||||
const session = await this.getSession(user);
|
||||
const body = JSON.stringify({
|
||||
using: [CORE_CAPABILITY],
|
||||
methodCalls: [
|
||||
[
|
||||
MANAGEMENT_PROBE_METHOD,
|
||||
{ accountId: session.accountId, ids: [], properties: ['name'] },
|
||||
'p',
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await this.exchange(
|
||||
session.apiPath,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: this.impersonation(user),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
},
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'management-check',
|
||||
async (response): Promise<ManagementProbeResult> => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
// 요청 전체 거부 — 엔진이 이 관리 호출의 형식을 바꾼 경우다 (Stalwart 는 인자 형식 오류를 400 으로 거부한다)
|
||||
if (response.status === HTTP_BAD_REQUEST) {
|
||||
const problem = await readProblem(response);
|
||||
return {
|
||||
kind: 'unclear',
|
||||
detail: `http-400/${problem?.type ?? 'unknown'}`,
|
||||
};
|
||||
}
|
||||
await this.assertOk(response, 'management-check');
|
||||
const parsed = await readJson<JmapResponseBody>(
|
||||
response,
|
||||
'management-check',
|
||||
);
|
||||
return classifyManagementProbe(parsed.methodResponses?.[0]);
|
||||
},
|
||||
);
|
||||
|
||||
if (result.kind === 'none') return;
|
||||
if (result.kind === 'management') {
|
||||
await this.cache.del(sessionCacheKey(user));
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 메일 엔진의 관리 권한이 있는 계정이라 웹메일로 다루지 않습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'management-account', user: maskEmail(user) },
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.managementProbeUnclearReported) {
|
||||
this.managementProbeUnclearReported = true;
|
||||
this.logger.error(
|
||||
`[MAIL_001]${traceTag()} 관리 권한 확인(${MANAGEMENT_PROBE_METHOD}) 응답을 해석하지 못해 이 확인 없이 진행합니다 — 메일 엔진 API 가 바뀌었는지 확인하세요. (응답: ${result.detail})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP 메서드를 호출한다. accountId 는 세션에서 정해지므로 호출 목록을 함수로 받는다.
|
||||
* 메서드 단위 오류(`["error", …]`)는 그대로 돌려주고, 해석은 호출한 쪽이 한다.
|
||||
*/
|
||||
async call(
|
||||
user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options: JmapCallOptions = {},
|
||||
): Promise<JmapInvocation[]> {
|
||||
const session = await this.getSession(user);
|
||||
const body = JSON.stringify({
|
||||
using: [...JMAP_USING, ...(options.capabilities ?? [])],
|
||||
methodCalls: build(session.accountId),
|
||||
});
|
||||
|
||||
return this.exchange(
|
||||
session.apiPath,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: this.impersonation(user),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
},
|
||||
options.timeoutMs ?? REQUEST_TIMEOUT_MS,
|
||||
'api',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) {
|
||||
const problem = await readProblem(response);
|
||||
const rejectedRequest =
|
||||
response.status === 400 &&
|
||||
(problem?.type ?? '').endsWith(':notRequest');
|
||||
|
||||
if (rejectedRequest && options.invalidRequestCode) {
|
||||
throw new BusinessException(options.invalidRequestCode, {
|
||||
context: { reason: 'request-rejected', jmapType: 'notRequest' },
|
||||
});
|
||||
}
|
||||
throw engineFailure({
|
||||
reason: 'http-error',
|
||||
call: 'api',
|
||||
status: response.status,
|
||||
jmapType: problem?.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
await this.assertOk(response, 'api');
|
||||
|
||||
const parsed = await readJson<JmapResponseBody>(response, 'api');
|
||||
if (!Array.isArray(parsed.methodResponses)) {
|
||||
throw engineFailure({ reason: 'malformed-response', call: 'api' });
|
||||
}
|
||||
return parsed.methodResponses;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부를 업로드한다 → blob id.
|
||||
* 본문은 Blob 으로 받는다 — 임시 파일을 연 Blob(fs.openAsBlob)을 넘기면 파일을 조금씩 읽어 흘려보내므로
|
||||
* 파일 전체를 메모리에 올리지 않는다. 크기를 알고 있어 Content-Length 도 정확히 나간다.
|
||||
*/
|
||||
async upload(
|
||||
user: string,
|
||||
content: Blob,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; type: string; size: number }> {
|
||||
const session = await this.getSession(user);
|
||||
const path = expandTemplate(session.uploadPath, {
|
||||
accountId: session.accountId,
|
||||
});
|
||||
|
||||
return this.exchange(
|
||||
path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: this.impersonation(user),
|
||||
'content-type': type,
|
||||
},
|
||||
body: content,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
'upload',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
if (response.status === HTTP_PAYLOAD_TOO_LARGE) {
|
||||
await discard(response);
|
||||
throw new BusinessException('FILE_002', {
|
||||
context: { reason: 'mail-engine-upload-limit' },
|
||||
});
|
||||
}
|
||||
if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) {
|
||||
throw this.uploadFailure(
|
||||
response.status,
|
||||
await readProblem(response),
|
||||
);
|
||||
}
|
||||
await this.assertOk(response, 'upload');
|
||||
|
||||
const result = await readJson<JmapUploadResult>(response, 'upload');
|
||||
if (typeof result.blobId !== 'string' || result.blobId === '') {
|
||||
throw engineFailure({ reason: 'malformed-response', call: 'upload' });
|
||||
}
|
||||
return {
|
||||
blobId: result.blobId,
|
||||
type: result.type ?? type,
|
||||
size: typeof result.size === 'number' ? result.size : content.size,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** blob 을 스트림으로 내려받는다 (응답 헤더까지만 시간 제한) */
|
||||
async download(
|
||||
user: string,
|
||||
blobId: string,
|
||||
name: string,
|
||||
type: string,
|
||||
): Promise<JmapDownload> {
|
||||
const session = await this.getSession(user);
|
||||
const path = expandTemplate(session.downloadPath, {
|
||||
accountId: session.accountId,
|
||||
blobId,
|
||||
name,
|
||||
type,
|
||||
});
|
||||
|
||||
const response = await this.exchange(
|
||||
path,
|
||||
{
|
||||
headers: {
|
||||
authorization: this.impersonation(user),
|
||||
// 압축을 받지 않는다 — fetch 가 자동으로 풀면 content-length(압축 크기)가 실제 길이와 달라진다
|
||||
'accept-encoding': 'identity',
|
||||
},
|
||||
},
|
||||
DOWNLOAD_HEADERS_TIMEOUT_MS,
|
||||
'download',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
if (response.status === HTTP_NOT_FOUND) {
|
||||
await discard(response);
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'blob-not-found' },
|
||||
});
|
||||
}
|
||||
await this.assertOk(response, 'download');
|
||||
|
||||
if (!response.body) {
|
||||
throw engineFailure({ reason: 'empty-body', call: 'download' });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
// 그래도 압축되어 왔다면 길이를 모르는 것으로 둔다 (잘못된 Content-Length 는 파일을 자른다)
|
||||
const encoding = response.headers.get('content-encoding');
|
||||
const compressed = encoding !== null && encoding !== 'identity';
|
||||
|
||||
return {
|
||||
// fetch 의 웹 스트림을 Node 스트림으로 — 파괴(destroy)하면 엔진 연결도 끊긴다
|
||||
stream: Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>),
|
||||
length: compressed
|
||||
? null
|
||||
: parseContentLength(response.headers.get('content-length')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 작은 blob 을 메모리로 읽는다. `maxBytes` 를 넘으면 즉시 멈추고 null.
|
||||
* (본문 내장 이미지를 data: URI 로 넣을 때 쓴다)
|
||||
*/
|
||||
async downloadBytes(
|
||||
user: string,
|
||||
blobId: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null> {
|
||||
const { stream, length } = await this.download(
|
||||
user,
|
||||
blobId,
|
||||
'inline',
|
||||
'application/octet-stream',
|
||||
);
|
||||
if (length !== null && length > maxBytes) {
|
||||
stream.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => stream.destroy(new Error('inline download timeout')),
|
||||
SMALL_DOWNLOAD_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk as Uint8Array);
|
||||
total += buffer.length;
|
||||
if (total > maxBytes) {
|
||||
stream.destroy();
|
||||
return null;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
} catch (error) {
|
||||
throw engineFailure({
|
||||
reason: 'download-interrupted',
|
||||
call: 'download',
|
||||
cause: describeError(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private requireConfigured(): ConfiguredMailSettings {
|
||||
if (!isMailEngineConfigured(this.settings)) {
|
||||
throw engineFailure({ reason: 'mail-not-configured' });
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/** 막는 계정(관리자·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */
|
||||
private assertNotBlocked(address: string): void {
|
||||
if (this.settings.blockedAccounts.has(address.trim().toLowerCase())) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'blocked-account', user: maskEmail(address) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private impersonation(user: string): string {
|
||||
const { gatewayUser, gatewayPassword } = this.requireConfigured();
|
||||
return basicAuthorization(`${user}%${gatewayUser}`, gatewayPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch 한 번을 제한 시간 안에서 수행한다. `read` 가 상태를 확인하고 본문을 읽는다.
|
||||
* 네트워크 오류·시간 초과는 MAIL_001 로 바꾼다.
|
||||
*/
|
||||
private async exchange<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
read: (response: Response) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { jmapBaseUrl } = this.requireConfigured();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${jmapBaseUrl}${path}`, {
|
||||
...init,
|
||||
// 엔진이 다른 곳으로 보내면 따라가지 않는다 (인증 헤더가 다른 호스트로 가지 않게)
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await read(response);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) throw error;
|
||||
throw engineFailure({
|
||||
reason: controller.signal.aborted ? 'timeout' : 'network',
|
||||
call: label,
|
||||
cause: describeError(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 2xx 가 아니면 코드로 바꿔 던진다 — 429 는 REQ_001, 나머지는 엔진 장애 */
|
||||
private async assertOk(response: Response, label: string): Promise<void> {
|
||||
if (response.ok) return;
|
||||
|
||||
await discard(response);
|
||||
if (response.status === HTTP_TOO_MANY_REQUESTS) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: { reason: 'mail-engine-rate-limit', call: label },
|
||||
});
|
||||
}
|
||||
throw engineFailure({
|
||||
reason: 'http-error',
|
||||
call: label,
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 대리 접근 거부를 해석한다 (Stalwart v0.16 개발 엔진에서 확인):
|
||||
* - **401**: 대상 계정이 없거나(다른 도메인 주소 포함) 게이트웨이 비밀번호가 틀렸다 — 응답이 같다.
|
||||
* 그래서 게이트웨이로 직접 로그인해 구분한다: 게이트웨이도 거부되면 설정 문제(MAIL_001), 아니면 계정 없음(AUTH_001)
|
||||
* - **403** "You do not have enough permissions": 인증은 됐지만 **대리 접근 권한이 없다** — 게이트웨이 설정 문제다.
|
||||
* 계정 없음으로 보면 설정 실수 한 번에 전 직원의 세션이 폐기된다(리프레시가 AUTH_001 → 세션 폐기).
|
||||
* 그래서 MAIL_001(503) + error 로그로 답하고 세션은 건드리지 않는다.
|
||||
*/
|
||||
private async assertImpersonationAccepted(
|
||||
response: Response,
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
response.status !== HTTP_UNAUTHORIZED &&
|
||||
response.status !== HTTP_FORBIDDEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await discard(response);
|
||||
await this.cache.del(sessionCacheKey(user));
|
||||
|
||||
if (response.status === HTTP_FORBIDDEN) {
|
||||
this.logger.error(
|
||||
`[MAIL_001]${traceTag()} 메일 게이트웨이 계정에 대리 접근 권한이 없습니다(403) — 계정 문제가 아니라 설정 문제로 보고 세션을 유지합니다. 게이트웨이 계정의 대리 접근(impersonate) 권한과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`,
|
||||
);
|
||||
throw engineFailure({
|
||||
reason: 'impersonation-forbidden',
|
||||
user: maskEmail(user),
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await this.gatewayAccepted())) {
|
||||
this.logger.error(
|
||||
`[MAIL_001]${traceTag()} 메일 게이트웨이 계정의 인증이 거부되었습니다. MAIL_GATEWAY_USER·MAIL_GATEWAY_PASSWORD 와 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`,
|
||||
);
|
||||
throw engineFailure({ reason: 'gateway-rejected' });
|
||||
}
|
||||
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'impersonation-rejected',
|
||||
status: response.status,
|
||||
user: maskEmail(user),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async gatewayAccepted(): Promise<boolean> {
|
||||
const { gatewayUser, gatewayPassword } = this.requireConfigured();
|
||||
return this.exchange(
|
||||
SESSION_PATH,
|
||||
{
|
||||
headers: {
|
||||
authorization: basicAuthorization(gatewayUser, gatewayPassword),
|
||||
},
|
||||
},
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'gateway-check',
|
||||
async (response) => {
|
||||
await discard(response);
|
||||
return response.status !== HTTP_UNAUTHORIZED;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private uploadFailure(
|
||||
status: number,
|
||||
problem: JmapProblem | null,
|
||||
): BusinessException {
|
||||
const context = {
|
||||
reason: 'http-error',
|
||||
call: 'upload',
|
||||
status,
|
||||
jmapType: problem?.type ?? 'unknown',
|
||||
};
|
||||
if (problem?.limit === 'maxSizeUpload') {
|
||||
return new BusinessException('FILE_002', { context });
|
||||
}
|
||||
if (/quota/i.test(`${problem?.type ?? ''} ${problem?.detail ?? ''}`)) {
|
||||
return new BusinessException('MAIL_003', { context });
|
||||
}
|
||||
return engineFailure(context);
|
||||
}
|
||||
|
||||
private toSession(resource: JmapSessionResource, user: string): JmapSession {
|
||||
const accountId = resource.primaryAccounts?.[MAIL_CAPABILITY];
|
||||
const { apiUrl, downloadUrl, uploadUrl } = resource;
|
||||
|
||||
if (!accountId || !apiUrl || !downloadUrl || !uploadUrl) {
|
||||
throw engineFailure({ reason: 'session-incomplete', call: 'session' });
|
||||
}
|
||||
return {
|
||||
username: resource.username?.trim().toLowerCase() || user,
|
||||
accountId,
|
||||
apiPath: toRelativeReference(apiUrl),
|
||||
downloadPath: toRelativeReference(downloadUrl),
|
||||
uploadPath: toRelativeReference(uploadUrl),
|
||||
maxAttachmentsBytes: readAttachmentsLimit(resource, accountId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
isMissingAttachmentBlob,
|
||||
jmapFailure,
|
||||
mapJmapErrorType,
|
||||
} from './jmap.errors';
|
||||
|
||||
describe('JMAP 오류 매핑', () => {
|
||||
it.each([
|
||||
['notFound', 'RES_001'],
|
||||
['invalidArguments', 'VAL_001'],
|
||||
['invalidProperties', 'VAL_001'],
|
||||
['blobNotFound', 'MAIL_004'],
|
||||
['forbidden', 'AUTH_003'],
|
||||
['forbiddenFrom', 'AUTH_003'],
|
||||
['forbiddenToSend', 'AUTH_003'],
|
||||
['overQuota', 'MAIL_003'],
|
||||
['tooLarge', 'FILE_002'],
|
||||
['rateLimit', 'REQ_001'],
|
||||
['invalidRecipients', 'MAIL_002'],
|
||||
['noRecipients', 'MAIL_002'],
|
||||
['tooManyRecipients', 'MAIL_002'],
|
||||
])('%s → %s', (type, code) => {
|
||||
expect(mapJmapErrorType(type)).toBe(code);
|
||||
});
|
||||
|
||||
it('예상하지 못한 type 은 MAIL_001 로 수렴한다', () => {
|
||||
for (const type of [
|
||||
'serverFail',
|
||||
'serverUnavailable',
|
||||
'unknownMethod',
|
||||
'stateMismatch',
|
||||
'accountNotFound',
|
||||
undefined,
|
||||
]) {
|
||||
expect(mapJmapErrorType(type)).toBe('MAIL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('로그 context 에 type 과 속성 이름만 남기고 엔진의 설명 문장은 남기지 않는다', () => {
|
||||
const exception = jmapFailure(
|
||||
{
|
||||
type: 'invalidProperties',
|
||||
description: 'Invalid address: someone@example.net',
|
||||
properties: ['to/0/email'],
|
||||
},
|
||||
{ method: 'Email/set' },
|
||||
);
|
||||
|
||||
expect(exception.code).toBe('VAL_001');
|
||||
expect(exception.context).toEqual({
|
||||
method: 'Email/set',
|
||||
jmapType: 'invalidProperties',
|
||||
properties: ['to/0/email'],
|
||||
});
|
||||
expect(JSON.stringify(exception.context)).not.toContain('example.net');
|
||||
});
|
||||
|
||||
it('오류 객체가 없으면 type 을 unknown 으로 기록한다', () => {
|
||||
const exception = jmapFailure(undefined, { method: 'Email/get' });
|
||||
|
||||
expect(exception.code).toBe('MAIL_001');
|
||||
expect(exception.context).toEqual({
|
||||
method: 'Email/get',
|
||||
jmapType: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('첨부 blob 을 찾지 못한 오류를 가려낸다 (Stalwart: invalidProperties + attachments/blobId)', () => {
|
||||
expect(isMissingAttachmentBlob({ type: 'blobNotFound' })).toBe(true);
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: ['attachments/blobId'],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: ['attachments/2/blobId'],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: ['attachments/type', 'to/0/email'],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isMissingAttachmentBlob({ type: 'overQuota' })).toBe(false);
|
||||
expect(isMissingAttachmentBlob(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { ErrorCode } from '@/common/errors/error-codes';
|
||||
import type { JmapError } from './jmap.types';
|
||||
|
||||
/**
|
||||
* JMAP 오류 type → 우리 에러 코드.
|
||||
* 메서드 오류(`["error", {type}]`)와 SetError(`notCreated`·`notUpdated`·`notDestroyed` 의 `{type}`)가 같은 표를 쓴다.
|
||||
* 표에 없는 type 은 예상하지 못한 엔진 오류이므로 MAIL_001 로 수렴한다 (type 은 로그 context 에 남긴다).
|
||||
*/
|
||||
const JMAP_ERROR_CODES: ReadonlyMap<string, ErrorCode> = new Map<
|
||||
string,
|
||||
ErrorCode
|
||||
>([
|
||||
['notFound', 'RES_001'],
|
||||
['invalidArguments', 'VAL_001'],
|
||||
['invalidProperties', 'VAL_001'],
|
||||
// 첨부 blob 을 찾지 못함 — 만료된 업로드·잘못된 첨부 id (RFC 8621 의 표준 type)
|
||||
['blobNotFound', 'MAIL_004'],
|
||||
['forbidden', 'AUTH_003'],
|
||||
['forbiddenFrom', 'AUTH_003'],
|
||||
['forbiddenToSend', 'AUTH_003'],
|
||||
['overQuota', 'MAIL_003'],
|
||||
['tooLarge', 'FILE_002'],
|
||||
['rateLimit', 'REQ_001'],
|
||||
['invalidRecipients', 'MAIL_002'],
|
||||
['noRecipients', 'MAIL_002'],
|
||||
['tooManyRecipients', 'MAIL_002'],
|
||||
]);
|
||||
|
||||
export function mapJmapErrorType(type: string | undefined): ErrorCode {
|
||||
return (type && JMAP_ERROR_CODES.get(type)) || 'MAIL_001';
|
||||
}
|
||||
|
||||
/** `attachments/blobId`, `attachments/0/blobId` — 첨부의 blob 참조 속성 */
|
||||
const ATTACHMENT_BLOB_PROPERTY = /^attachments(?:\/\d+)?\/blobId$/;
|
||||
|
||||
/**
|
||||
* 첨부 blob 을 엔진에서 찾지 못한 오류인지 — MAIL_004 로 응답한다.
|
||||
* RFC 8621 은 `blobNotFound` 를 정의하지만, Stalwart(v0.16)는 없는 blob 을
|
||||
* `invalidProperties` + `properties: ["attachments/blobId"]` 로 알려 준다 (확인함).
|
||||
*/
|
||||
export function isMissingAttachmentBlob(error: JmapError | undefined): boolean {
|
||||
if (error?.type === 'blobNotFound') return true;
|
||||
return (
|
||||
error?.type === 'invalidProperties' &&
|
||||
(error.properties ?? []).some((property) =>
|
||||
ATTACHMENT_BLOB_PROPERTY.test(property),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP 오류를 코드가 있는 예외로 바꾼다.
|
||||
* description 은 엔진이 만든 문장(주소 등이 섞일 수 있다)이라 남기지 않고 type·속성 이름만 남긴다.
|
||||
*/
|
||||
export function jmapFailure(
|
||||
error: JmapError | undefined,
|
||||
context: Record<string, unknown>,
|
||||
): BusinessException {
|
||||
const type = error?.type;
|
||||
return new BusinessException(mapJmapErrorType(type), {
|
||||
context: {
|
||||
...context,
|
||||
jmapType: type ?? 'unknown',
|
||||
...(error?.properties?.length ? { properties: error.properties } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
|
||||
import type {
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailUsage,
|
||||
MessageDetailSource,
|
||||
MessageSummary,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import type {
|
||||
JmapBodyPart,
|
||||
JmapEmail,
|
||||
JmapEmailAddress,
|
||||
JmapIdentity,
|
||||
JmapMailbox,
|
||||
JmapQuota,
|
||||
} from './jmap.types';
|
||||
|
||||
/**
|
||||
* JMAP 객체 → 우리 도메인 타입 (순수 함수).
|
||||
* 엔진 응답은 외부 입력이므로 모든 필드를 방어적으로 읽는다.
|
||||
*/
|
||||
|
||||
/** 목록·요약에 필요한 Email 속성 */
|
||||
export const SUMMARY_PROPERTIES = [
|
||||
'id',
|
||||
'threadId',
|
||||
'mailboxIds',
|
||||
'keywords',
|
||||
'from',
|
||||
'to',
|
||||
'subject',
|
||||
'preview',
|
||||
'receivedAt',
|
||||
'hasAttachment',
|
||||
'size',
|
||||
];
|
||||
|
||||
/** 상세에 필요한 Email 속성 */
|
||||
export const DETAIL_PROPERTIES = [
|
||||
...SUMMARY_PROPERTIES,
|
||||
'cc',
|
||||
'bcc',
|
||||
'replyTo',
|
||||
'sentAt',
|
||||
'messageId',
|
||||
'references',
|
||||
'bodyValues',
|
||||
'htmlBody',
|
||||
'textBody',
|
||||
'attachments',
|
||||
];
|
||||
|
||||
/** 본문 부분(part)에서 읽는 속성 */
|
||||
export const BODY_PART_PROPERTIES = [
|
||||
'partId',
|
||||
'blobId',
|
||||
'size',
|
||||
'name',
|
||||
'type',
|
||||
'charset',
|
||||
'disposition',
|
||||
'cid',
|
||||
];
|
||||
|
||||
export const MAILBOX_PROPERTIES = [
|
||||
'id',
|
||||
'name',
|
||||
'role',
|
||||
'parentId',
|
||||
'sortOrder',
|
||||
'totalEmails',
|
||||
'unreadEmails',
|
||||
];
|
||||
|
||||
const KNOWN_ROLES: ReadonlySet<string> = new Set<MailboxRole>([
|
||||
'inbox',
|
||||
'drafts',
|
||||
'sent',
|
||||
'junk',
|
||||
'trash',
|
||||
'archive',
|
||||
]);
|
||||
|
||||
const OCTET_STREAM = 'application/octet-stream';
|
||||
|
||||
/** 이름 없는 첨부의 표시 이름 접두어 */
|
||||
const UNNAMED_ATTACHMENT = '첨부파일';
|
||||
|
||||
/** 이름 없는 첨부에 붙일 확장자 */
|
||||
const EXTENSION_BY_TYPE: ReadonlyMap<string, string> = new Map([
|
||||
['image/png', '.png'],
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/gif', '.gif'],
|
||||
['image/webp', '.webp'],
|
||||
['application/pdf', '.pdf'],
|
||||
['text/plain', '.txt'],
|
||||
['text/calendar', '.ics'],
|
||||
['message/rfc822', '.eml'],
|
||||
]);
|
||||
|
||||
/** 날짜를 ISO 8601 UTC 로 맞춘다 (JMAP sentAt 은 시간대 오프셋이 붙어 온다) */
|
||||
export function toUtcIso(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const time = Date.parse(value);
|
||||
return Number.isNaN(time) ? null : new Date(time).toISOString();
|
||||
}
|
||||
|
||||
export function toAddress(
|
||||
address: JmapEmailAddress | null | undefined,
|
||||
): MailAddress | null {
|
||||
const email = address?.email?.trim();
|
||||
if (!email) return null;
|
||||
|
||||
const name = address?.name?.trim();
|
||||
return { name: name ? name : null, email };
|
||||
}
|
||||
|
||||
export function toAddresses(
|
||||
addresses: JmapEmailAddress[] | null | undefined,
|
||||
): MailAddress[] {
|
||||
return (addresses ?? []).flatMap((address) => {
|
||||
const mapped = toAddress(address);
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function toMailboxRole(
|
||||
role: string | null | undefined,
|
||||
): MailboxRole | null {
|
||||
const normalized = role?.toLowerCase();
|
||||
return normalized && KNOWN_ROLES.has(normalized)
|
||||
? (normalized as MailboxRole)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function toMailbox(mailbox: JmapMailbox): Mailbox {
|
||||
return {
|
||||
id: mailbox.id,
|
||||
name: mailbox.name ?? '',
|
||||
role: toMailboxRole(mailbox.role),
|
||||
parentId: mailbox.parentId ?? null,
|
||||
sortOrder: mailbox.sortOrder ?? 0,
|
||||
totalCount: mailbox.totalEmails ?? 0,
|
||||
unreadCount: mailbox.unreadEmails ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function trueKeys(record: Record<string, boolean> | undefined): string[] {
|
||||
return Object.entries(record ?? {})
|
||||
.filter(([, value]) => value === true)
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
export function toSummary(email: JmapEmail): MessageSummary {
|
||||
return {
|
||||
id: email.id,
|
||||
threadId: email.threadId ?? email.id,
|
||||
mailboxIds: trueKeys(email.mailboxIds),
|
||||
from: toAddress(email.from?.[0]),
|
||||
to: toAddresses(email.to),
|
||||
subject: email.subject ?? '',
|
||||
preview: email.preview ?? '',
|
||||
receivedAt: toUtcIso(email.receivedAt) ?? new Date(0).toISOString(),
|
||||
isRead: email.keywords?.$seen === true,
|
||||
isFlagged: email.keywords?.$flagged === true,
|
||||
hasAttachment: email.hasAttachment === true,
|
||||
size: email.size ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function partType(part: JmapBodyPart): string {
|
||||
return (part.type ?? OCTET_STREAM).trim().toLowerCase() || OCTET_STREAM;
|
||||
}
|
||||
|
||||
/** 첨부 표시 이름 — 이름이 없으면 '첨부파일-2.png' 처럼 만든다 */
|
||||
export function attachmentName(part: JmapBodyPart, index: number): string {
|
||||
const name = part.name?.trim();
|
||||
if (name) return sanitizeFileName(name);
|
||||
return `${UNNAMED_ATTACHMENT}-${index + 1}${EXTENSION_BY_TYPE.get(partType(part)) ?? ''}`;
|
||||
}
|
||||
|
||||
/** blob 이 있는(내려받을 수 있는) 첨부만 */
|
||||
function downloadableAttachments(
|
||||
email: JmapEmail,
|
||||
): Array<JmapBodyPart & { blobId: string }> {
|
||||
return (email.attachments ?? []).filter(
|
||||
(part): part is JmapBodyPart & { blobId: string } =>
|
||||
typeof part.blobId === 'string' && part.blobId !== '',
|
||||
);
|
||||
}
|
||||
|
||||
function toAttachment(
|
||||
part: JmapBodyPart & { blobId: string },
|
||||
index: number,
|
||||
): MailAttachment {
|
||||
return {
|
||||
id: part.blobId,
|
||||
name: attachmentName(part, index),
|
||||
type: partType(part),
|
||||
size: part.size ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정한 형식의 본문 부분 값을 이어 붙인다. 그 형식 부분이 없으면 null.
|
||||
* (JMAP 의 htmlBody 는 HTML 이 없으면 text/plain 부분으로 채워지므로 형식을 확인해야 한다)
|
||||
*/
|
||||
function joinBodyValues(
|
||||
email: JmapEmail,
|
||||
parts: JmapBodyPart[] | undefined,
|
||||
type: string,
|
||||
): string | null {
|
||||
const values = (parts ?? [])
|
||||
.filter((part) => partType(part) === type && part.partId)
|
||||
.map((part) => email.bodyValues?.[part.partId as string]?.value ?? '');
|
||||
|
||||
return values.length > 0 ? values.join('\n') : null;
|
||||
}
|
||||
|
||||
export function toDetailSource(email: JmapEmail): MessageDetailSource {
|
||||
const attachments = downloadableAttachments(email);
|
||||
const inlineParts: InlinePart[] = attachments
|
||||
.filter((part) => typeof part.cid === 'string' && part.cid.trim() !== '')
|
||||
.map((part) => ({
|
||||
attachmentId: part.blobId,
|
||||
cid: (part.cid as string).trim(),
|
||||
type: partType(part),
|
||||
size: part.size ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
...toSummary(email),
|
||||
cc: toAddresses(email.cc),
|
||||
bcc: toAddresses(email.bcc),
|
||||
replyTo: toAddresses(email.replyTo),
|
||||
sentAt: toUtcIso(email.sentAt),
|
||||
messageId: email.messageId?.[0] ?? null,
|
||||
references: email.references ?? [],
|
||||
rawHtmlBody: joinBodyValues(email, email.htmlBody, 'text/html'),
|
||||
textBody: joinBodyValues(email, email.textBody, 'text/plain'),
|
||||
attachments: attachments.map(toAttachment),
|
||||
inlineParts,
|
||||
};
|
||||
}
|
||||
|
||||
/** 첨부 목록에서 blob id 로 첨부를 찾는다 (그 메일에 실제로 있는지 확인용) */
|
||||
export function findAttachment(
|
||||
email: JmapEmail,
|
||||
attachmentId: string,
|
||||
): { name: string; size: number | null } | null {
|
||||
const attachments = downloadableAttachments(email);
|
||||
const index = attachments.findIndex((part) => part.blobId === attachmentId);
|
||||
if (index < 0) return null;
|
||||
|
||||
return {
|
||||
name: attachmentName(attachments[index], index),
|
||||
size: attachments[index].size ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 메일함의 바이트 쿼터를 고른다 (RFC 9425).
|
||||
* 도메인·전체(global) 범위 쿼터의 `used` 는 직원 한 명의 사용량이 아니므로 계정 범위만 쓴다.
|
||||
* 여러 개면 가장 빠듯한(작은) 한도를 쓴다.
|
||||
*/
|
||||
export function selectAccountQuota(quotas: JmapQuota[]): JmapQuota | null {
|
||||
const candidates = quotas.filter(
|
||||
(quota) =>
|
||||
quota.resourceType === 'octets' &&
|
||||
quota.scope === 'account' &&
|
||||
typeof quota.used === 'number' &&
|
||||
(!quota.types ||
|
||||
quota.types.length === 0 ||
|
||||
quota.types.includes('Mail')),
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const limitOf = (quota: JmapQuota) =>
|
||||
typeof quota.hardLimit === 'number' && quota.hardLimit > 0
|
||||
? quota.hardLimit
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return [...candidates].sort((a, b) => limitOf(a) - limitOf(b))[0];
|
||||
}
|
||||
|
||||
/** 쿼터 → 사용량 (한도 0·없음은 "한도 없음") */
|
||||
export function toUsage(quota: JmapQuota): MailUsage {
|
||||
return {
|
||||
usedBytes: quota.used ?? 0,
|
||||
limitBytes:
|
||||
typeof quota.hardLimit === 'number' && quota.hardLimit > 0
|
||||
? quota.hardLimit
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
|
||||
export function pickIdentity(
|
||||
identities: JmapIdentity[],
|
||||
email: string,
|
||||
): JmapIdentity | null {
|
||||
return (
|
||||
identities.find((identity) => identity.email?.toLowerCase() === email) ??
|
||||
identities[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** 표시 이름이 비어 있을 때 쓰는 이름 — 주소의 로컬 부분 */
|
||||
export function fallbackDisplayName(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
return at > 0 ? email.slice(0, at) : email;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* JMAP(RFC 8620 · 8621) 타입 중 이 어댑터가 쓰는 부분만 정의한다.
|
||||
* 메일 엔진의 응답은 외부 입력이므로 모든 필드를 선택적으로 두고, 매핑할 때 방어적으로 읽는다.
|
||||
* 이 타입은 `modules/mail/jmap/` 밖으로 내보내지 않는다.
|
||||
*/
|
||||
|
||||
/** [메서드 이름, 인자, 호출 id] */
|
||||
export type JmapInvocation = [string, Record<string, unknown>, string];
|
||||
|
||||
export interface JmapResponseBody {
|
||||
methodResponses?: JmapInvocation[];
|
||||
}
|
||||
|
||||
/** 요청 단위 오류 (RFC 7807 problem details) */
|
||||
export interface JmapProblem {
|
||||
type?: string;
|
||||
status?: number;
|
||||
limit?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface JmapSessionResource {
|
||||
username?: string;
|
||||
apiUrl?: string;
|
||||
downloadUrl?: string;
|
||||
uploadUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
/** 계정별 기능·한도 — 메일 기능의 `maxSizeAttachmentsPerEmail` 를 읽는다 */
|
||||
accounts?: Record<
|
||||
string,
|
||||
{ accountCapabilities?: Record<string, Record<string, unknown>> }
|
||||
>;
|
||||
}
|
||||
|
||||
/** 메서드 오류(`["error", {...}]`)·SetError 공통 형태 */
|
||||
export interface JmapError {
|
||||
type?: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
export interface JmapEmailAddress {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface JmapBodyPart {
|
||||
partId?: string | null;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
charset?: string | null;
|
||||
disposition?: string | null;
|
||||
cid?: string | null;
|
||||
}
|
||||
|
||||
export interface JmapBodyValue {
|
||||
value?: string;
|
||||
isTruncated?: boolean;
|
||||
isEncodingProblem?: boolean;
|
||||
}
|
||||
|
||||
export interface JmapEmail {
|
||||
id: string;
|
||||
threadId?: string;
|
||||
mailboxIds?: Record<string, boolean>;
|
||||
keywords?: Record<string, boolean>;
|
||||
from?: JmapEmailAddress[] | null;
|
||||
to?: JmapEmailAddress[] | null;
|
||||
cc?: JmapEmailAddress[] | null;
|
||||
bcc?: JmapEmailAddress[] | null;
|
||||
replyTo?: JmapEmailAddress[] | null;
|
||||
subject?: string | null;
|
||||
preview?: string;
|
||||
receivedAt?: string;
|
||||
sentAt?: string | null;
|
||||
hasAttachment?: boolean;
|
||||
size?: number;
|
||||
messageId?: string[] | null;
|
||||
references?: string[] | null;
|
||||
bodyValues?: Record<string, JmapBodyValue>;
|
||||
htmlBody?: JmapBodyPart[];
|
||||
textBody?: JmapBodyPart[];
|
||||
attachments?: JmapBodyPart[];
|
||||
}
|
||||
|
||||
export interface JmapMailbox {
|
||||
id: string;
|
||||
name?: string;
|
||||
role?: string | null;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
totalEmails?: number;
|
||||
unreadEmails?: number;
|
||||
}
|
||||
|
||||
export interface JmapIdentity {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface JmapGetResult<T> {
|
||||
list?: T[];
|
||||
notFound?: string[];
|
||||
}
|
||||
|
||||
export interface JmapSetResult {
|
||||
created?: Record<string, { id?: string }>;
|
||||
updated?: Record<string, unknown>;
|
||||
destroyed?: string[];
|
||||
notCreated?: Record<string, JmapError>;
|
||||
notUpdated?: Record<string, JmapError>;
|
||||
notDestroyed?: Record<string, JmapError>;
|
||||
}
|
||||
|
||||
export interface JmapQueryResult {
|
||||
ids?: string[];
|
||||
position?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/** RFC 9425 Quota */
|
||||
export interface JmapQuota {
|
||||
id: string;
|
||||
resourceType?: string;
|
||||
used?: number;
|
||||
hardLimit?: number;
|
||||
scope?: string;
|
||||
types?: string[];
|
||||
}
|
||||
|
||||
/** RFC 9404 Blob/get 결과 항목 */
|
||||
export interface JmapBlob {
|
||||
id: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface JmapUploadResult {
|
||||
blobId?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Controller,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { DeleteUploadedFileInterceptor } from '@/common/interceptors/delete-uploaded-file.interceptor';
|
||||
import { allowedAttachmentExtensions } from './attachment.policy';
|
||||
import { MailAttachmentDto } from './dto/mail-response.dto';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
// 메일 첨부 업로드 — modules/upload 패턴을 따른다.
|
||||
// 형식·크기 검증은 MailModule 의 multer 설정(임시 디스크 저장)이 담당하고, 파일은 메일 엔진으로 흘려보낸 뒤 지운다.
|
||||
// 가드가 인터셉터보다 먼저 돌므로 로그인하지 않은·다른 사이트에서 온 요청은 파일을 읽기 전에 막힌다.
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-attachments')
|
||||
export class MailAttachmentController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Post()
|
||||
// 순서 중요: FileInterceptor 가 임시 파일을 받고, 그 뒤의 정리 인터셉터가 핸들러가 끝나면 지운다
|
||||
@UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor)
|
||||
@ApiOperation({
|
||||
summary: '메일 첨부 업로드 (파일 1개)',
|
||||
description: `허용 형식(확장자 + MIME): ${allowedAttachmentExtensions().join(', ')}. 크기 한도는 GET /api/mail-quota 의 attachmentMaxBytes.`,
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { file: { type: 'string', format: 'binary' } },
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(MailAttachmentDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '업로드 완료 — 이 id 를 발송 본문 attachments 에 넣는다',
|
||||
})
|
||||
@ApiErrorResponse('FILE_001', '허용되지 않는 형식')
|
||||
@ApiErrorResponse('FILE_002', '파일 크기 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
upload(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'attachment-file-missing' },
|
||||
});
|
||||
}
|
||||
return this.mail.uploadAttachment(user, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_ID_PATTERN } from './mail.constants';
|
||||
|
||||
/**
|
||||
* 경로의 메일·첨부 id 형식 확인.
|
||||
* 형식이 틀린 id 는 "그런 대상이 없다"와 같으므로 RES_001 로 응답한다 (엔진까지 보내지 않는다).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && MAIL_ID_PATTERN.test(value)) return value;
|
||||
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-mail-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
MailUploadTempCleanup,
|
||||
removeStaleUploads,
|
||||
} from './mail-upload-storage';
|
||||
|
||||
const HOUR_MS = 60 * 60_000;
|
||||
|
||||
describe('removeStaleUploads', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'mail-uploads-'));
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('오래된 파일만 지운다 — 진행 중일 수 있는 최근 파일은 남긴다', async () => {
|
||||
const old = join(dir, 'old');
|
||||
const fresh = join(dir, 'fresh');
|
||||
writeFileSync(old, 'x');
|
||||
writeFileSync(fresh, 'y');
|
||||
const twoHoursAgo = (Date.now() - 2 * HOUR_MS) / 1000;
|
||||
utimesSync(old, twoHoursAgo, twoHoursAgo);
|
||||
|
||||
await expect(removeStaleUploads(dir, HOUR_MS)).resolves.toBe(1);
|
||||
expect(existsSync(old)).toBe(false);
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
});
|
||||
|
||||
it('디렉터리가 없으면 0 (첫 기동)', async () => {
|
||||
await expect(
|
||||
removeStaleUploads(join(dir, 'missing'), HOUR_MS),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailUploadTempCleanup (기동 때 + 주기 정리)', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'mail-uploads-cleanup-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function cleanupIn(directory: string): MailUploadTempCleanup {
|
||||
const cleanup = new MailUploadTempCleanup();
|
||||
Reflect.set(cleanup, 'directory', directory);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
it('주기 정리는 오래된 임시 첨부만 지우고, 지웠으면 경고를 남긴다', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const stale = join(dir, 'stale');
|
||||
writeFileSync(stale, 'x');
|
||||
writeFileSync(join(dir, 'in-progress'), 'y');
|
||||
const twoHoursAgo = (Date.now() - 2 * HOUR_MS) / 1000;
|
||||
utimesSync(stale, twoHoursAgo, twoHoursAgo);
|
||||
|
||||
await expect(cleanupIn(dir).removeStale()).resolves.toBe(1);
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
expect(existsSync(join(dir, 'in-progress'))).toBe(true);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('임시 첨부 1개를 지웠습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('주기 작업으로 등록되어 있다 (@Interval) — 기동 때 한 번만 도는 것이 아니다', () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
MailUploadTempCleanup.prototype,
|
||||
'removeStale',
|
||||
);
|
||||
const keys = Reflect.getMetadataKeys(descriptor?.value as object);
|
||||
|
||||
expect(
|
||||
keys.some((key) => String(key).toUpperCase().includes('SCHEDULE')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('지울 것이 없으면 조용히 끝난다', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await expect(cleanupIn(dir).removeStale()).resolves.toBe(0);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readdir, rm, stat } from 'fs/promises';
|
||||
import { diskStorage, type StorageEngine } from 'multer';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* 메일 첨부의 임시 저장소.
|
||||
*
|
||||
* 첨부는 메모리가 아니라 **임시 디스크**에 받은 뒤 메일 엔진으로 흘려보내고 지운다.
|
||||
* 메모리에 받으면 파일 하나(최대 20MB)가 요청이 끝날 때까지 통째로 프로세스 메모리에 머문다 —
|
||||
* 운영 메모리 한도(1GiB)에서 동시 업로드 수십 건이면 컨테이너가 OOM 으로 죽고 다른 사용자의 요청까지 끊긴다.
|
||||
* 디스크에 받으면 메모리는 업로드 수와 무관하게 거의 일정하다 (대신 임시 디스크 공간이 업로드 수 × 파일 크기만큼 필요하다).
|
||||
*
|
||||
* 이 디렉터리는 정적으로 서빙하지 않는다 — 파일은 엔진으로 넘긴 뒤 바로 지운다.
|
||||
*/
|
||||
export const MAIL_UPLOAD_TMP_DIR = join(tmpdir(), 'arasystem-mail-uploads');
|
||||
|
||||
/** 이보다 오래된 임시 파일은 끝나지 못한 업로드가 남긴 것이다 (비정상 종료·지우기 실패 등) */
|
||||
const STALE_UPLOAD_AGE_MS = 60 * 60_000;
|
||||
|
||||
/** 남은 임시 첨부를 찾는 주기 */
|
||||
const STALE_UPLOAD_SWEEP_INTERVAL_MS = 15 * 60_000;
|
||||
|
||||
/** multer 디스크 저장 — 파일명은 서버가 만든다 (클라이언트가 보낸 이름을 경로에 쓰지 않는다) */
|
||||
export function mailUploadStorage(): StorageEngine {
|
||||
return diskStorage({
|
||||
destination: MAIL_UPLOAD_TMP_DIR,
|
||||
filename: (_req, _file, callback) => callback(null, randomUUID()),
|
||||
});
|
||||
}
|
||||
|
||||
/** `dir` 안에서 `maxAgeMs` 보다 오래된 파일을 지운다 → 지운 수 (디렉터리가 없으면 0) */
|
||||
export async function removeStaleUploads(
|
||||
dir: string,
|
||||
maxAgeMs: number,
|
||||
now: number = Date.now(),
|
||||
): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
const path = join(dir, name);
|
||||
try {
|
||||
const info = await stat(path);
|
||||
if (info.isFile() && now - info.mtimeMs > maxAgeMs) {
|
||||
await rm(path, { force: true });
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
// 그 사이 다른 쪽이 지웠다 — 무시한다
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 남은 임시 첨부 정리 — 기동할 때 한 번, 그 뒤 15분마다(ScheduleModule).
|
||||
* 요청마다 지우지만(DeleteUploadedFileInterceptor) 프로세스가 업로드 도중 죽거나 지우기가 실패하면 파일이 남는다.
|
||||
* 진행 중인 업로드를 건드리지 않도록 오래된(1시간) 파일만 지운다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailUploadTempCleanup implements OnModuleInit {
|
||||
private readonly logger = new Logger(MailUploadTempCleanup.name);
|
||||
/** 정리할 디렉터리 — 테스트에서 바꿔 끼울 수 있게 속성으로 둔다 */
|
||||
protected readonly directory: string = MAIL_UPLOAD_TMP_DIR;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.removeStale();
|
||||
}
|
||||
|
||||
@Interval(STALE_UPLOAD_SWEEP_INTERVAL_MS)
|
||||
async removeStale(now: number = Date.now()): Promise<number> {
|
||||
const removed = await removeStaleUploads(
|
||||
this.directory,
|
||||
STALE_UPLOAD_AGE_MS,
|
||||
now,
|
||||
);
|
||||
if (removed > 0) {
|
||||
// 정상이면 요청이 끝날 때 지워진다 — 남아 있었다는 것은 비정상 종료·지우기 실패의 흔적이다
|
||||
this.logger.warn(
|
||||
`남아 있던 임시 첨부 ${removed}개를 지웠습니다 (업로드 도중 비정상 종료 또는 지우기 실패).`,
|
||||
);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 외부로 노출하는 메일 객체 id 형식 (메일함·메일·첨부).
|
||||
* JMAP 의 Id 정의(RFC 8620 §1.2: URL-safe base64 문자 1~255자)를 따른다 — 엔진을 바꿔도 유지되는 형식이다.
|
||||
*/
|
||||
export const MAIL_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;
|
||||
|
||||
/** 받는 사람(to + cc + bcc) 합계 상한 */
|
||||
export const MAX_RECIPIENTS = 100;
|
||||
|
||||
/** 제목 최대 길이 */
|
||||
export const MAX_SUBJECT_LENGTH = 255;
|
||||
|
||||
/** 본문(일반 텍스트) 최대 길이 */
|
||||
export const MAX_BODY_LENGTH = 100_000;
|
||||
|
||||
/** 검색어 최대 길이 */
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
/** 받는 사람 표시 이름 최대 길이 */
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) */
|
||||
export const MAX_EMAIL_LENGTH = 254;
|
||||
|
||||
/** 첨부 파일명 최대 길이 */
|
||||
export const MAX_FILE_NAME_LENGTH = 255;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MulterModule,
|
||||
type MulterModuleOptions,
|
||||
} from '@nestjs/platform-express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isAllowedAttachment } from './attachment.policy';
|
||||
import { JmapClient } from './jmap/jmap.client';
|
||||
import { JmapMailProvider } from './jmap/jmap-mail.provider';
|
||||
import { MailAttachmentController } from './mail-attachment.controller';
|
||||
import {
|
||||
MailUploadTempCleanup,
|
||||
mailUploadStorage,
|
||||
} from './mail-upload-storage';
|
||||
import { MAIL_PROVIDER } from './mail.provider';
|
||||
import { MailService } from './mail.service';
|
||||
import {
|
||||
MAIL_SETTINGS,
|
||||
type MailSettings,
|
||||
buildMailSettings,
|
||||
isMailEngineConfigured,
|
||||
} from './mail.settings';
|
||||
import { MailboxController, MailQuotaController } from './mailbox.controller';
|
||||
import { MessageController } from './message.controller';
|
||||
|
||||
function readMailSettings(config: ConfigService): MailSettings {
|
||||
return buildMailSettings((key) => config.get<string>(key));
|
||||
}
|
||||
|
||||
function createMailSettings(config: ConfigService): MailSettings {
|
||||
const settings = readMailSettings(config);
|
||||
if (!isMailEngineConfigured(settings)) {
|
||||
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다
|
||||
new Logger('MailModule').warn(
|
||||
'메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일 API 가 MAIL_001 을 돌려줍니다.',
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 첨부 업로드 설정 — 임시 디스크에 받아 메일 엔진으로 흘려보내고 지운다 (mail-upload-storage.ts).
|
||||
* 메모리에 통째로 올리지 않는다 (운영 메모리 한도 1GiB — 동시 업로드에 버티게).
|
||||
* 형식 검사는 확장자 + MIME 허용 목록 (FILE_001), 크기 초과는 multer → 413 (FILE_002).
|
||||
*/
|
||||
function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
return {
|
||||
storage: mailUploadStorage(),
|
||||
limits: {
|
||||
fileSize: readMailSettings(config).attachmentMaxBytes,
|
||||
files: 1,
|
||||
fields: 10,
|
||||
},
|
||||
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!isAllowedAttachment(file.originalname, file.mimetype)) {
|
||||
callback(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'attachment-type-not-allowed' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
|
||||
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
|
||||
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: attachmentUploadOptions,
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MessageController,
|
||||
MailAttachmentController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAIL_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: createMailSettings,
|
||||
},
|
||||
JmapClient,
|
||||
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
|
||||
MailService,
|
||||
MailUploadTempCleanup,
|
||||
],
|
||||
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
AttachmentContent,
|
||||
MailAccountProfile,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailUsage,
|
||||
MessageDetailSource,
|
||||
MessageListQuery,
|
||||
MessagePage,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
SentMessageRecord,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
|
||||
/**
|
||||
* 메일 엔진 포트 — 서비스 계층은 이 인터페이스만 안다.
|
||||
*
|
||||
* 지금 구현은 Stalwart(JMAP) 하나(`JmapMailProvider`)지만, 엔진을 바꿀 때는
|
||||
* 이 인터페이스의 새 구현만 만들어 `MAIL_PROVIDER` 에 연결하면 된다.
|
||||
*
|
||||
* 규칙:
|
||||
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입·id 형식을 밖으로 흘리지 않는다)
|
||||
* - 실패는 항상 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001, 없는 대상 RES_001 등
|
||||
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 메일함에만 접근한다
|
||||
*/
|
||||
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다.
|
||||
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null
|
||||
*/
|
||||
verifyCredentials(email: string, password: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 직원 메일함 프로필(표시 이름). `fresh` 면 캐시를 건너뛰고 계정이 살아 있는지 다시 확인한다.
|
||||
* 계정이 사라졌거나, 로그인을 막는 계정(관리자 등)이거나, 메일 엔진의 관리 권한이 있는 계정이면 AUTH_001.
|
||||
* (관리 권한 확인은 `fresh` 일 때 — 로그인·리프레시마다 — 한다)
|
||||
*/
|
||||
getProfile(
|
||||
user: string,
|
||||
options?: { fresh?: boolean },
|
||||
): Promise<MailAccountProfile>;
|
||||
|
||||
listMailboxes(user: string): Promise<Mailbox[]>;
|
||||
|
||||
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
|
||||
getUsage(user: string): Promise<MailUsage>;
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — `sendMessage` 가 FILE_002 로 거절하는 기준과 **같은 값**이어야 한다
|
||||
* (`attachment.policy.ts` 의 `resolveAttachmentsTotalLimit` 하나로 정한다).
|
||||
* 엔진 정보를 읽지 못하면 다른 호출과 같은 실패(MAIL_001·AUTH_001)로 던진다.
|
||||
*/
|
||||
getAttachmentsTotalLimit(user: string): Promise<number>;
|
||||
|
||||
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
|
||||
|
||||
/** 조회만 한다 — 읽음 처리하지 않는다 */
|
||||
getMessage(user: string, id: string): Promise<MessageDetailSource>;
|
||||
|
||||
updateMessage(
|
||||
user: string,
|
||||
id: string,
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary>;
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
|
||||
deleteMessage(user: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* @returns 보낸편지함에 남은 메일 id
|
||||
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
|
||||
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
|
||||
*/
|
||||
sendMessage(user: string, message: OutgoingMessage): Promise<{ id: string }>;
|
||||
|
||||
/**
|
||||
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
|
||||
* 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다).
|
||||
*/
|
||||
findSentMessage(
|
||||
user: string,
|
||||
messageId: string,
|
||||
): Promise<SentMessageRecord | null>;
|
||||
|
||||
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
|
||||
|
||||
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
|
||||
openAttachment(
|
||||
user: string,
|
||||
messageId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent>;
|
||||
|
||||
/**
|
||||
* 메일 안의 작은 부분(본문 내장 이미지 등)을 메모리로 읽는다.
|
||||
* `maxBytes` 를 넘으면 읽기를 멈추고 null 을 돌려준다.
|
||||
*/
|
||||
readAttachmentBytes(
|
||||
user: string,
|
||||
attachmentId: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null>;
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SendMessageDto } from './dto/send-message.dto';
|
||||
import { MailService } from './mail.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
import type { MessageDetailSource } from './mail.types';
|
||||
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
const IDEMPOTENCY_KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set(['admin@arasystem.test']),
|
||||
};
|
||||
|
||||
/** 포트의 각 메서드를 타입이 있는 jest.fn 속성으로 — 메서드가 아니라 속성이라 그대로 expect 에 넘길 수 있다 */
|
||||
type ProviderMocks = {
|
||||
[K in keyof MailProvider]: jest.Mock<
|
||||
ReturnType<MailProvider[K]>,
|
||||
Parameters<MailProvider[K]>
|
||||
>;
|
||||
};
|
||||
|
||||
/** 포트 메서드 하나의 타입이 있는 mock */
|
||||
function mockOf<K extends keyof MailProvider>(): ProviderMocks[K] {
|
||||
return jest.fn();
|
||||
}
|
||||
|
||||
function fakeProvider(): ProviderMocks {
|
||||
const sendMessage = mockOf<'sendMessage'>();
|
||||
sendMessage.mockResolvedValue({ id: 'sent-1' });
|
||||
|
||||
return {
|
||||
verifyCredentials: mockOf<'verifyCredentials'>(),
|
||||
getProfile: mockOf<'getProfile'>(),
|
||||
listMailboxes: mockOf<'listMailboxes'>(),
|
||||
getUsage: mockOf<'getUsage'>(),
|
||||
getAttachmentsTotalLimit:
|
||||
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
|
||||
listMessages: mockOf<'listMessages'>(),
|
||||
getMessage: mockOf<'getMessage'>(),
|
||||
updateMessage: mockOf<'updateMessage'>(),
|
||||
deleteMessage: mockOf<'deleteMessage'>(),
|
||||
sendMessage,
|
||||
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
|
||||
uploadAttachment: mockOf<'uploadAttachment'>(),
|
||||
openAttachment: mockOf<'openAttachment'>(),
|
||||
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
|
||||
};
|
||||
}
|
||||
|
||||
async function codeOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = (
|
||||
overrides: Partial<MessageDetailSource> = {},
|
||||
): MessageDetailSource => ({
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['a'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: null, email: user.email }],
|
||||
subject: '제목',
|
||||
preview: '미리보기',
|
||||
receivedAt: '2026-10-26T05:12:00.000Z',
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
size: 1000,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: 'abc@x',
|
||||
references: [],
|
||||
rawHtmlBody: null,
|
||||
textBody: '본문',
|
||||
attachments: [],
|
||||
inlineParts: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
subject: '안녕하세요',
|
||||
body: '본문',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('MailService', () => {
|
||||
let provider: ProviderMocks;
|
||||
let service: MailService;
|
||||
let logSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = fakeProvider();
|
||||
service = new MailService(provider, settings);
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('메일함은 한국어 이름·확정 순서로, 한 페이지에 모두 내려준다', async () => {
|
||||
provider.listMailboxes.mockResolvedValue([
|
||||
{
|
||||
id: 'd',
|
||||
name: 'Drafts',
|
||||
role: 'drafts',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'e',
|
||||
name: 'Sent Items',
|
||||
role: 'sent',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.listMailboxes(user);
|
||||
|
||||
expect(result.items.map((m) => m.name)).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
]);
|
||||
expect(result.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('사용량에 서버의 첨부 한도를 함께 내려준다', async () => {
|
||||
provider.getUsage.mockResolvedValue({ usedBytes: 900, limitBytes: null });
|
||||
|
||||
await expect(service.getQuota(user)).resolves.toEqual({
|
||||
usedBytes: 900,
|
||||
limitBytes: null,
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('첨부 합계 한도는 어댑터가 발송 검사와 같은 함수로 정한 값을 그대로 내려준다 (엔진 한도가 더 작은 경우 포함)', async () => {
|
||||
provider.getUsage.mockResolvedValue({ usedBytes: 0, limitBytes: null });
|
||||
provider.getAttachmentsTotalLimit.mockResolvedValue(10 * 1024 * 1024);
|
||||
|
||||
const quota = await service.getQuota(user);
|
||||
|
||||
expect(quota.attachmentTotalMaxBytes).toBe(10 * 1024 * 1024);
|
||||
expect(provider.getAttachmentsTotalLimit).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
|
||||
it('사용량이나 첨부 한도를 엔진에서 읽지 못하면 그 실패를 그대로 던진다 (MAIL_001)', async () => {
|
||||
provider.getUsage.mockResolvedValue({ usedBytes: 0, limitBytes: null });
|
||||
provider.getAttachmentsTotalLimit.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
|
||||
|
||||
provider.getUsage.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => {
|
||||
provider.listMessages.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' });
|
||||
await service.listMessages(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
q: ' 이전 ',
|
||||
});
|
||||
|
||||
expect(provider.listMessages.mock.calls[0][1]).toMatchObject({
|
||||
text: undefined,
|
||||
});
|
||||
expect(provider.listMessages.mock.calls[1][1]).toMatchObject({
|
||||
text: '이전',
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 상세', () => {
|
||||
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
|
||||
provider.getMessage.mockResolvedValue(source());
|
||||
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
});
|
||||
expect(detail).not.toHaveProperty('rawHtmlBody');
|
||||
expect(detail).not.toHaveProperty('inlineParts');
|
||||
});
|
||||
|
||||
it('HTML 은 정화해서 내려준다 (원본을 그대로 내보내지 않는다)', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody:
|
||||
'<p onclick="x()">안녕</p><script>alert(1)</script><img src="https://t.example/p.gif">',
|
||||
}),
|
||||
);
|
||||
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(detail.htmlBody).toBe('<p>안녕</p>');
|
||||
expect(detail.hasBlockedImages).toBe(true);
|
||||
});
|
||||
|
||||
it('본문이 참조하는 작은 내장 이미지만 읽어 data: URI 로 넣는다', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<img src="cid:logo@x"><img src="cid:big@x">',
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-big',
|
||||
cid: 'big@x',
|
||||
type: 'image/png',
|
||||
size: 5 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-unused',
|
||||
cid: 'unused@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(provider.readAttachmentBytes).toHaveBeenCalledTimes(1);
|
||||
expect(provider.readAttachmentBytes).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'blob-logo',
|
||||
300 * 1024,
|
||||
);
|
||||
expect(detail.htmlBody).toBe('<img src="data:image/png;base64,AQID" />');
|
||||
});
|
||||
|
||||
it('내장 이미지 하나를 읽지 못해도 메일은 열린다', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<p>본문</p><img src="cid:logo@x">',
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
|
||||
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
|
||||
htmlBody: '<p>본문</p>',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('PATCH 는 하나 이상 지정해야 한다', async () => {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
Promise.resolve(service.updateMessage(user, 'm1', {})),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
expect(provider.updateMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('발송', () => {
|
||||
it('받는 사람 합계가 100명을 넘으면 MAIL_002 (엔진에 보내지 않는다)', async () => {
|
||||
const many = (prefix: string, count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: null,
|
||||
email: `${prefix}${i}@arasystem.test`,
|
||||
}));
|
||||
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
to: many('t', 50),
|
||||
cc: many('c', 30),
|
||||
bcc: many('b', 21),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBe('MAIL_002');
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('첨부가 20개를 넘으면 MAIL_006 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다 (엔진에 보내지 않는다)', async () => {
|
||||
const attachments = Array.from({ length: 21 }, (_, i) => ({
|
||||
id: `blob-${i}`,
|
||||
name: i % 2 === 0 ? `파일${i}.pdf` : `원본${i}.xlsx`,
|
||||
}));
|
||||
|
||||
expect(
|
||||
await codeOf(() => service.sendMessage(user, sendDto({ attachments }))),
|
||||
).toBe('MAIL_006');
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('첨부 개수는 형식보다 먼저 본다 — 20개를 넘으면 허용 밖 형식이 섞여 있어도 MAIL_006', async () => {
|
||||
const attachments = Array.from({ length: 21 }, (_, i) => ({
|
||||
id: `blob-${i}`,
|
||||
name: i === 0 ? 'virus.exe' : `파일${i}.pdf`,
|
||||
}));
|
||||
|
||||
expect(
|
||||
await codeOf(() => service.sendMessage(user, sendDto({ attachments }))),
|
||||
).toBe('MAIL_006');
|
||||
});
|
||||
|
||||
it('첨부 20개까지는 보낸다', async () => {
|
||||
const attachments = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `blob-${i}`,
|
||||
name: `파일${i}.pdf`,
|
||||
}));
|
||||
|
||||
await service.sendMessage(user, sendDto({ attachments }));
|
||||
|
||||
expect(provider.sendMessage.mock.calls[0][1].attachments).toHaveLength(
|
||||
20,
|
||||
);
|
||||
});
|
||||
|
||||
it('첨부 형식은 클라이언트 값이 아니라 이름의 확장자로 정한다', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({ attachments: [{ id: 'blob-1', name: '체크리스트.xlsx' }] }),
|
||||
);
|
||||
|
||||
expect(provider.sendMessage.mock.calls[0][1].attachments).toEqual([
|
||||
{
|
||||
id: 'blob-1',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('전달: 원본 메일의 첨부 id(같은 계정 blob)를 그대로 넘긴다 — 업로드 여부를 따지지 않는다', async () => {
|
||||
const originalAttachmentId =
|
||||
'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae';
|
||||
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
subject: 'Fwd: 메일 서버 이전 일정 공유',
|
||||
attachments: [{ id: originalAttachmentId, name: '일정표.pdf' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.attachments).toEqual([
|
||||
{
|
||||
id: originalAttachmentId,
|
||||
name: '일정표.pdf',
|
||||
type: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
expect(outgoing.inReplyTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('허용 목록 밖의 확장자는 업로드와 같게 FILE_001 이다 (전달 포함)', async () => {
|
||||
for (const name of ['원본.eml', 'virus.exe', 'page.html', '이름없음']) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ attachments: [{ id: 'blob-1', name }] }),
|
||||
),
|
||||
),
|
||||
).toBe('FILE_001');
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('제목·표시 이름의 줄바꿈은 공백으로 바꾼다 (헤더 분리 방지)', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
subject: '제목\r\nBcc: evil@example.net',
|
||||
to: [
|
||||
{ name: '이도윤\nX-Evil: 1', email: 'doyun.lee@arasystem.test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.subject).toBe('제목 Bcc: evil@example.net');
|
||||
expect(outgoing.to[0].name).toBe('이도윤 X-Evil: 1');
|
||||
});
|
||||
|
||||
it('발송 로그에는 마스킹한 보낸 사람과 개수만 남긴다 (제목·본문·주소 없음)', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
subject: '비밀 제목',
|
||||
body: '비밀 본문',
|
||||
cc: [{ name: null, email: 'seojun.park@arasystem.test' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const logged = logSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(logged).toContain('from=a***@arasystem.test');
|
||||
expect(logged).toContain('받는 사람 2명');
|
||||
expect(logged).not.toMatch(/비밀|seojun|doyun/);
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "중복 발송 방지 — Idempotency-Key" */
|
||||
describe('발송 중복 방지 (Idempotency-Key)', () => {
|
||||
const send = (dto = sendDto(), key: string | undefined = IDEMPOTENCY_KEY) =>
|
||||
service.sendMessage(user, dto, key);
|
||||
|
||||
/** 이 키·이 내용으로 첫 발송 때 남는 지문 */
|
||||
const fingerprintOf = async (dto: SendMessageDto): Promise<string> => {
|
||||
provider.sendMessage.mockClear();
|
||||
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
|
||||
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
|
||||
if (!fingerprint) throw new Error('지문 없이 보냈다');
|
||||
return fingerprint;
|
||||
};
|
||||
|
||||
it('키가 없으면 찾아보지 않고 그대로 보낸다', async () => {
|
||||
await service.sendMessage(user, sendDto());
|
||||
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.messageId).toBeUndefined();
|
||||
expect(outgoing.fingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('키 형식이 틀리면 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
expect(await codeOf(() => send(sendDto(), 'not-a-uuid'))).toBe('VAL_001');
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => {
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
const messageId = idempotentMessageId(user.email, IDEMPOTENCY_KEY);
|
||||
expect(provider.findSentMessage).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
messageId,
|
||||
);
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.messageId).toBe(messageId);
|
||||
// 지문 키는 이 요청의 Idempotency-Key — 서버 비밀이 아니다 (v2)
|
||||
expect(outgoing.fingerprint).toBe(
|
||||
payloadFingerprint(outgoing, IDEMPOTENCY_KEY),
|
||||
);
|
||||
expect(outgoing.fingerprint).toMatch(/^v2:/);
|
||||
});
|
||||
|
||||
it('다른 키로 같은 내용을 보내면 지문도 다르다 — 이전 메일과 엮이지 않고 새로 보낸다', async () => {
|
||||
const otherKey = '00000000-0000-4000-8000-000000000000';
|
||||
await send(sendDto(), IDEMPOTENCY_KEY);
|
||||
await send(sendDto(), otherKey);
|
||||
|
||||
const [[, first], [, second]] = provider.sendMessage.mock.calls;
|
||||
expect(first.fingerprint).not.toBe(second.fingerprint);
|
||||
expect(first.messageId).not.toBe(second.messageId);
|
||||
});
|
||||
|
||||
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
|
||||
const fingerprint = await fingerprintOf(sendDto());
|
||||
provider.sendMessage.mockClear();
|
||||
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
|
||||
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
|
||||
const fingerprint = await fingerprintOf(sendDto());
|
||||
provider.sendMessage.mockClear();
|
||||
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
|
||||
|
||||
for (const changed of [
|
||||
sendDto({ subject: '고친 제목' }),
|
||||
sendDto({ body: '고친 본문' }),
|
||||
sendDto({ cc: [{ name: null, email: 'seojun.park@arasystem.test' }] }),
|
||||
sendDto({ attachments: [{ id: 'blob-1', name: '추가.pdf' }] }),
|
||||
]) {
|
||||
expect(await codeOf(() => send(changed))).toBe('MAIL_005');
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지문 없이 남은 메일(헤더가 지워진 경우 등)도 같은 내용인지 알 수 없으므로 MAIL_005', async () => {
|
||||
provider.findSentMessage.mockResolvedValue({
|
||||
id: 'sent-1',
|
||||
fingerprint: null,
|
||||
});
|
||||
|
||||
expect(await codeOf(() => send())).toBe('MAIL_005');
|
||||
});
|
||||
|
||||
it('같은 키의 발송이 처리 중이면 기다리지 않고 429 REQ_001 — 끝나면 다시 받는다', async () => {
|
||||
let finish!: (value: { id: string }) => void;
|
||||
provider.sendMessage.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
);
|
||||
|
||||
const first = send();
|
||||
// 첫 요청이 엔진에 보내는 중이 될 때까지 기다린다
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(await codeOf(() => send())).toBe('REQ_001');
|
||||
// 다른 키·다른 사용자는 막지 않는다
|
||||
await expect(
|
||||
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
finish({ id: 'sent-first' });
|
||||
await expect(first).resolves.toEqual({ id: 'sent-first' });
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
});
|
||||
|
||||
it('엔진 장애(MAIL_001)로 끝나면 엔진이 아직 처리 중일 수 있어 30초 동안 같은 키를 REQ_001 로 막는다', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
try {
|
||||
provider.sendMessage.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
|
||||
);
|
||||
|
||||
expect(await codeOf(() => send())).toBe('MAIL_001');
|
||||
expect(await codeOf(() => send())).toBe('REQ_001');
|
||||
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('엔진 장애가 아닌 실패(형식 오류 등)는 곧바로 같은 키로 다시 보낼 수 있다', async () => {
|
||||
provider.sendMessage.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_004'),
|
||||
);
|
||||
|
||||
expect(await codeOf(() => send())).toBe('MAIL_004');
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
});
|
||||
|
||||
it('이미 보낸 메일을 찾는 중 엔진 장애면 잠그지 않는다 (보낸 것이 없다)', async () => {
|
||||
provider.findSentMessage.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
|
||||
expect(await codeOf(() => send())).toBe('MAIL_001');
|
||||
await expect(send()).resolves.toEqual({ id: 'sent-1' });
|
||||
});
|
||||
});
|
||||
|
||||
it('업로드는 정리한 이름과 확장자로 정한 형식을 엔진에 넘긴다', async () => {
|
||||
provider.uploadAttachment.mockResolvedValue({
|
||||
id: 'blob-1',
|
||||
name: '보고서.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 3,
|
||||
});
|
||||
|
||||
await service.uploadAttachment(user, {
|
||||
originalname: 'C:\\temp\\보고서.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
path: '/tmp/arasystem-mail-uploads/5f0c',
|
||||
size: 3,
|
||||
} as Express.Multer.File);
|
||||
|
||||
// 파일 내용은 메모리로 넘기지 않는다 — multer 가 받은 임시 파일 경로만 넘긴다
|
||||
expect(provider.uploadAttachment).toHaveBeenCalledWith(user.email, {
|
||||
path: '/tmp/arasystem-mail-uploads/5f0c',
|
||||
size: 3,
|
||||
name: '보고서.pdf',
|
||||
type: 'application/pdf',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
resolveAttachmentType,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
import type { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import type { RecipientDto, SendMessageDto } from './dto/send-message.dto';
|
||||
import type { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import {
|
||||
INLINE_IMAGES_TOTAL_MAX_BYTES,
|
||||
INLINE_IMAGE_MAX_BYTES,
|
||||
findReferencedContentIds,
|
||||
normalizeContentId,
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
} from './html/inline-images';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { MAX_RECIPIENTS } from './mail.constants';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailQuota,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
OutgoingAttachment,
|
||||
OutgoingMessage,
|
||||
} from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
import {
|
||||
idempotentMessageId,
|
||||
parseIdempotencyKey,
|
||||
payloadFingerprint,
|
||||
} from './send-idempotency';
|
||||
|
||||
/**
|
||||
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
|
||||
* 시간 초과·연결 끊김이면 엔진이 아직 그 메일을 처리하고 있을 수 있다 — 곧바로 온 재시도가
|
||||
* "아직 안 보냈다"고 보고 한 번 더 보내지 않게, 이 시간 동안은 처리 중(REQ_001)으로 답한다.
|
||||
*/
|
||||
const UNCERTAIN_SEND_HOLD_MS = 30_000;
|
||||
|
||||
/** 헤더에 들어가는 값(제목·표시 이름)의 줄바꿈을 공백으로 — 헤더 분리 시도를 막는다 */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function toAddress(recipient: RecipientDto): MailAddress {
|
||||
const name = recipient.name ? singleLine(recipient.name) : '';
|
||||
return { name: name || null, email: recipient.email.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
/**
|
||||
* 처리 중인 (사용자, Idempotency-Key) — 같은 키가 동시에 오면 기다리지 않고 REQ_001.
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 같은 키가 두 인스턴스에서 동시에 나가지 않는다.
|
||||
*/
|
||||
private readonly sendsInProgress = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
) {}
|
||||
|
||||
async listMailboxes(
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PaginatedResult<Mailbox>> {
|
||||
const items = presentMailboxes(
|
||||
await this.provider.listMailboxes(user.email),
|
||||
);
|
||||
// 메일함은 한 번에 모두 내려준다 — 목록 응답 형식만 맞춘다
|
||||
return toPaginatedResult(items, {
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 + 첨부 한도. 합계 한도는 어댑터가 발송 검사와 같은 함수로 정한 값을 그대로 내려준다
|
||||
* (화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않게).
|
||||
* 사용량을 먼저 읽어 엔진 세션이 캐시된 뒤 한도를 읽는다 — 세션을 두 번 받아 오지 않는다.
|
||||
* 어느 쪽이든 엔진에서 읽지 못하면 그 실패(MAIL_001 등)를 그대로 던진다.
|
||||
*/
|
||||
async getQuota(user: AuthenticatedUser): Promise<MailQuota> {
|
||||
const usage = await this.provider.getUsage(user.email);
|
||||
const attachmentTotalMaxBytes =
|
||||
await this.provider.getAttachmentsTotalLimit(user.email);
|
||||
return {
|
||||
...usage,
|
||||
attachmentMaxBytes: this.settings.attachmentMaxBytes,
|
||||
attachmentTotalMaxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
const text = query.q?.trim();
|
||||
const page = await this.provider.listMessages(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
text: text ? text : undefined,
|
||||
});
|
||||
|
||||
return toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다 */
|
||||
async getMessage(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<MessageDetail> {
|
||||
const { rawHtmlBody, inlineParts, ...detail } =
|
||||
await this.provider.getMessage(user.email, id);
|
||||
|
||||
if (rawHtmlBody === null) {
|
||||
return { ...detail, htmlBody: null, hasBlockedImages: false };
|
||||
}
|
||||
|
||||
const inlineImages = await this.loadInlineImages(
|
||||
user.email,
|
||||
rawHtmlBody,
|
||||
inlineParts,
|
||||
);
|
||||
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
|
||||
return {
|
||||
...detail,
|
||||
htmlBody: sanitized.html,
|
||||
hasBlockedImages: sanitized.hasBlockedImages,
|
||||
};
|
||||
}
|
||||
|
||||
async updateMessage(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: UpdateMessageDto,
|
||||
): Promise<MessageSummary> {
|
||||
const { isRead, isFlagged, mailboxId } = dto;
|
||||
if (
|
||||
isRead === undefined &&
|
||||
isFlagged === undefined &&
|
||||
mailboxId === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-update' },
|
||||
});
|
||||
}
|
||||
return this.provider.updateMessage(user.email, id, {
|
||||
isRead,
|
||||
isFlagged,
|
||||
mailboxId,
|
||||
});
|
||||
}
|
||||
|
||||
deleteMessage(user: AuthenticatedUser, id: string): Promise<void> {
|
||||
return this.provider.deleteMessage(user.email, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
|
||||
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
|
||||
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
|
||||
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
|
||||
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
|
||||
*/
|
||||
async sendMessage(
|
||||
user: AuthenticatedUser,
|
||||
dto: SendMessageDto,
|
||||
idempotencyKey?: string,
|
||||
): Promise<{ id: string }> {
|
||||
const parsed = parseIdempotencyKey(idempotencyKey);
|
||||
if (!parsed.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid-idempotency-key' },
|
||||
});
|
||||
}
|
||||
|
||||
const message = this.prepareOutgoing(dto);
|
||||
if (parsed.key === null) return this.deliver(user, message);
|
||||
return this.deliverOnce(user, message, parsed.key);
|
||||
}
|
||||
|
||||
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
|
||||
private async deliverOnce(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
key: string,
|
||||
): Promise<{ id: string }> {
|
||||
const lockKey = `${user.email.toLowerCase()}\n${key}`;
|
||||
if (this.sendsInProgress.has(lockKey)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'idempotency-key-in-progress',
|
||||
from: maskEmail(user.email),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.sendsInProgress.add(lockKey);
|
||||
|
||||
let holdLock = false;
|
||||
try {
|
||||
const messageId = idempotentMessageId(user.email, key);
|
||||
// 지문 키는 이 요청의 Idempotency-Key 다 — 서버 비밀을 쓰지 않는다 (send-idempotency.ts 머리말)
|
||||
const fingerprint = payloadFingerprint(message, key);
|
||||
|
||||
const previous = await this.provider.findSentMessage(
|
||||
user.email,
|
||||
messageId,
|
||||
);
|
||||
if (previous) {
|
||||
if (previous.fingerprint !== fingerprint) {
|
||||
throw new BusinessException('MAIL_005', {
|
||||
context: {
|
||||
reason: 'idempotency-key-reused-with-different-payload',
|
||||
from: maskEmail(user.email),
|
||||
sentId: previous.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}`,
|
||||
);
|
||||
return { id: previous.id };
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.deliver(user, { ...message, messageId, fingerprint });
|
||||
} catch (error) {
|
||||
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다
|
||||
holdLock =
|
||||
error instanceof BusinessException && error.code === 'MAIL_001';
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (holdLock) {
|
||||
setTimeout(
|
||||
() => this.sendsInProgress.delete(lockKey),
|
||||
UNCERTAIN_SEND_HOLD_MS,
|
||||
).unref();
|
||||
} else {
|
||||
this.sendsInProgress.delete(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 형식(FILE_001), 헤더 값 줄바꿈 */
|
||||
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
|
||||
const to = dto.to.map(toAddress);
|
||||
const cc = (dto.cc ?? []).map(toAddress);
|
||||
const bcc = (dto.bcc ?? []).map(toAddress);
|
||||
|
||||
const recipientCount = to.length + cc.length + bcc.length;
|
||||
if (recipientCount > MAX_RECIPIENTS) {
|
||||
throw new BusinessException('MAIL_002', {
|
||||
context: { reason: 'too-many-recipients', count: recipientCount },
|
||||
});
|
||||
}
|
||||
|
||||
// 첨부 개수 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다 (한도는 attachment.policy.ts)
|
||||
const requested = dto.attachments ?? [];
|
||||
if (requested.length > MAX_ATTACHMENTS_PER_MESSAGE) {
|
||||
throw new BusinessException('MAIL_006', {
|
||||
context: { reason: 'too-many-attachments', count: requested.length },
|
||||
});
|
||||
}
|
||||
|
||||
// 업로드한 파일이든 전달하는 원본 첨부든 같은 규칙 — 이름의 확장자로 허용 목록에서 형식을 정한다
|
||||
const attachments = requested.map((attachment): OutgoingAttachment => {
|
||||
const name = sanitizeFileName(attachment.name);
|
||||
const type = resolveAttachmentType(name);
|
||||
if (!type) {
|
||||
throw new BusinessException('FILE_001', {
|
||||
context: { reason: 'attachment-type-not-allowed' },
|
||||
});
|
||||
}
|
||||
return { id: attachment.id, name, type };
|
||||
});
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
};
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
): Promise<{ id: string }> {
|
||||
const result = await this.provider.sendMessage(user.email, message);
|
||||
|
||||
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md)
|
||||
const recipientCount =
|
||||
message.to.length + message.cc.length + message.bcc.length;
|
||||
this.logger.log(
|
||||
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async uploadAttachment(
|
||||
user: AuthenticatedUser,
|
||||
file: Express.Multer.File,
|
||||
): Promise<MailAttachment> {
|
||||
const name = sanitizeFileName(file.originalname);
|
||||
const type = resolveAttachmentType(name);
|
||||
if (!type) {
|
||||
// multer fileFilter 가 먼저 막지만, 이름 정리 후 확장자가 바뀌는 경우까지 한 번 더 확인한다
|
||||
throw new BusinessException('FILE_001', {
|
||||
context: { reason: 'attachment-type-not-allowed' },
|
||||
});
|
||||
}
|
||||
// multer 가 임시 파일에 받아 둔 것을 엔진으로 흘려보낸다 — 임시 파일은 요청이 끝나면 인터셉터가 지운다
|
||||
return this.provider.uploadAttachment(user.email, {
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
name,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
openAttachment(
|
||||
user: AuthenticatedUser,
|
||||
messageId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
return this.provider.openAttachment(user.email, messageId, attachmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문이 참조하는 작은 내장 이미지를 data: URI 로 준비한다.
|
||||
* 하나를 읽지 못해도 메일 전체를 실패시키지 않는다 — 그 이미지만 빠진다.
|
||||
*/
|
||||
private async loadInlineImages(
|
||||
user: string,
|
||||
html: string,
|
||||
parts: readonly InlinePart[],
|
||||
): Promise<Map<string, string>> {
|
||||
const images = new Map<string, string>();
|
||||
const candidates = selectInlineImages(
|
||||
parts,
|
||||
findReferencedContentIds(html),
|
||||
);
|
||||
if (candidates.length === 0) return images;
|
||||
|
||||
const loaded = await Promise.all(
|
||||
candidates.map(async (part) => {
|
||||
try {
|
||||
return {
|
||||
part,
|
||||
data: await this.provider.readAttachmentBytes(
|
||||
user,
|
||||
part.attachmentId,
|
||||
INLINE_IMAGE_MAX_BYTES,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 본문 내장 이미지를 읽지 못해 건너뜁니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return { part, data: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let total = 0;
|
||||
for (const { part, data } of loaded) {
|
||||
const fits =
|
||||
data !== null && total + data.length <= INLINE_IMAGES_TOTAL_MAX_BYTES;
|
||||
if (!fits) continue;
|
||||
|
||||
const dataUri = toDataUri(part.type, data);
|
||||
if (!dataUri) continue;
|
||||
|
||||
total += data.length;
|
||||
images.set(normalizeContentId(part.cid), dataUri);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
|
||||
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB,
|
||||
buildMailSettings,
|
||||
isMailEngineConfigured,
|
||||
parseAccountList,
|
||||
} from './mail.settings';
|
||||
|
||||
const reader =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('buildMailSettings', () => {
|
||||
it('내부 JMAP 주소는 origin 만 남긴다 (세션의 경로를 이 origin 에 붙인다)', () => {
|
||||
const settings = buildMailSettings(
|
||||
reader({ MAIL_JMAP_URL: 'http://mail:8080/' }),
|
||||
);
|
||||
|
||||
expect(settings.jmapBaseUrl).toBe('http://mail:8080');
|
||||
});
|
||||
|
||||
it('도메인·게이트웨이 주소는 소문자로 맞춘다', () => {
|
||||
const settings = buildMailSettings(
|
||||
reader({
|
||||
MAIL_DOMAIN: 'AraSystem.Test',
|
||||
MAIL_GATEWAY_USER: 'Mail-Gateway@AraSystem.Test',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(settings.domain).toBe('arasystem.test');
|
||||
expect(settings.gatewayUser).toBe('mail-gateway@arasystem.test');
|
||||
});
|
||||
|
||||
it('빈 값은 미설정으로 본다 (compose 가 미정의 변수를 빈 문자열로 주입한다)', () => {
|
||||
const settings = buildMailSettings(
|
||||
reader({
|
||||
MAIL_JMAP_URL: '',
|
||||
MAIL_DOMAIN: ' ',
|
||||
MAIL_GATEWAY_USER: '',
|
||||
MAIL_GATEWAY_PASSWORD: '',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(isMailEngineConfigured(settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('필수값이 모두 있어야 설정된 것으로 본다', () => {
|
||||
const complete = {
|
||||
MAIL_JMAP_URL: 'http://mail:8080',
|
||||
MAIL_DOMAIN: 'arasystem.test',
|
||||
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test',
|
||||
MAIL_GATEWAY_PASSWORD: 'secret-value',
|
||||
};
|
||||
|
||||
expect(isMailEngineConfigured(buildMailSettings(reader(complete)))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isMailEngineConfigured(
|
||||
buildMailSettings(reader({ ...complete, MAIL_GATEWAY_PASSWORD: '' })),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('첨부 한도는 MB 단위 env 를 바이트로 바꾸고, 없거나 잘못되면 기본값이다', () => {
|
||||
expect(
|
||||
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '5' }))
|
||||
.attachmentMaxBytes,
|
||||
).toBe(5 * MB);
|
||||
expect(buildMailSettings(reader({})).attachmentMaxBytes).toBe(
|
||||
DEFAULT_ATTACHMENT_MAX_SIZE_MB * MB,
|
||||
);
|
||||
expect(
|
||||
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '' }))
|
||||
.attachmentMaxBytes,
|
||||
).toBe(DEFAULT_ATTACHMENT_MAX_SIZE_MB * MB);
|
||||
});
|
||||
|
||||
it('첨부 합계 한도는 기본 25MB, env 로 바꿀 수 있다', () => {
|
||||
expect(buildMailSettings(reader({})).attachmentTotalMaxBytes).toBe(
|
||||
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB * MB,
|
||||
);
|
||||
expect(
|
||||
buildMailSettings(reader({ MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '40' }))
|
||||
.attachmentTotalMaxBytes,
|
||||
).toBe(40 * MB);
|
||||
});
|
||||
|
||||
it('파일 하나 한도만 올리면 합계 한도도 그만큼은 된다 — 큰 파일 하나를 못 보내는 설정이 되지 않게', () => {
|
||||
expect(
|
||||
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '30' }))
|
||||
.attachmentTotalMaxBytes,
|
||||
).toBe(30 * MB);
|
||||
});
|
||||
|
||||
it('메일 관리자 아이디 기본값은 admin — admin@<도메인> 을 설정 없이도 막고, MAIL_BLOCKED_ACCOUNTS 로 더한다 (소문자)', () => {
|
||||
expect([
|
||||
...buildMailSettings(reader({ MAIL_DOMAIN: 'AraSystem.Test' }))
|
||||
.blockedAccounts,
|
||||
]).toEqual(['admin@arasystem.test']);
|
||||
|
||||
expect([
|
||||
...buildMailSettings(
|
||||
reader({
|
||||
MAIL_DOMAIN: 'arasystem.test',
|
||||
MAIL_BLOCKED_ACCOUNTS: ' Ops@AraSystem.test, ,audit@arasystem.test ',
|
||||
}),
|
||||
).blockedAccounts,
|
||||
]).toEqual([
|
||||
'admin@arasystem.test',
|
||||
'ops@arasystem.test',
|
||||
'audit@arasystem.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('관리자 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾸면 그 주소를 막고, admin@ 은 직원 메일함으로 쓸 수 있다', () => {
|
||||
const blocked = buildMailSettings(
|
||||
reader({
|
||||
MAIL_DOMAIN: 'arasystem.test',
|
||||
MAIL_ADMIN_LOCAL_PART: ' Mail-Admin ',
|
||||
MAIL_BLOCKED_ACCOUNTS: 'ops@arasystem.test',
|
||||
}),
|
||||
).blockedAccounts;
|
||||
|
||||
expect([...blocked]).toEqual([
|
||||
'mail-admin@arasystem.test',
|
||||
'ops@arasystem.test',
|
||||
]);
|
||||
expect(blocked.has('admin@arasystem.test')).toBe(false);
|
||||
});
|
||||
|
||||
it('관리자 아이디가 빈 값이면 기본값 admin (compose 가 미정의 변수를 빈 문자열로 주입한다)', () => {
|
||||
expect([
|
||||
...buildMailSettings(
|
||||
reader({ MAIL_DOMAIN: 'arasystem.test', MAIL_ADMIN_LOCAL_PART: ' ' }),
|
||||
).blockedAccounts,
|
||||
]).toEqual(['admin@arasystem.test']);
|
||||
});
|
||||
|
||||
it('도메인이 없으면 목록에 적은 계정만 막는다', () => {
|
||||
expect([
|
||||
...buildMailSettings(reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }))
|
||||
.blockedAccounts,
|
||||
]).toEqual(['ops@x.test']);
|
||||
expect(buildMailSettings(reader({})).blockedAccounts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAccountList', () => {
|
||||
it('콤마로 나누고 앞뒤 공백·빈 항목을 버리며 소문자로 맞춘다', () => {
|
||||
expect(parseAccountList(' A@x.test,,b@X.test , ')).toEqual([
|
||||
'a@x.test',
|
||||
'b@x.test',
|
||||
]);
|
||||
expect(parseAccountList(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
* 메일 엔진 연결 설정 — 루트 `.env` 에서 compose 가 주입한다 (mail/README.md "백엔드 연결 정보").
|
||||
*
|
||||
* 비운영 환경에서는 값이 없어도 기동한다. 그때 메일 API 는 MAIL_001 을 돌려준다.
|
||||
* (운영은 env.validation.ts 가 필수로 요구한다)
|
||||
*/
|
||||
export const MAIL_SETTINGS = Symbol('MAIL_SETTINGS');
|
||||
|
||||
export interface MailSettings {
|
||||
/** 내부 JMAP 주소의 origin (예: http://mail:8080) — 세션 응답의 공개 주소 대신 이 주소에 경로만 붙인다 */
|
||||
readonly jmapBaseUrl: string | null;
|
||||
/** 사내 메일 도메인 (소문자) — 이 도메인 주소만 로그인할 수 있다 */
|
||||
readonly domain: string | null;
|
||||
/** 대리 접근 전용 게이트웨이 계정 주소 전체 (소문자) — 직접 로그인은 막는다 */
|
||||
readonly gatewayUser: string | null;
|
||||
readonly gatewayPassword: string | null;
|
||||
/** 첨부 1개 최대 크기 (바이트) */
|
||||
readonly attachmentMaxBytes: number;
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 최대 크기 (바이트) — 넘으면 FILE_002.
|
||||
* 받는 쪽 메일 서버가 큰 메일을 거부하는 일이 많아(흔히 25MB) 엔진 한도(50MB)보다 낮게 둔다.
|
||||
* 첨부 1개 한도보다 작지 않다 (파일 하나는 항상 보낼 수 있게).
|
||||
*/
|
||||
readonly attachmentTotalMaxBytes: number;
|
||||
/**
|
||||
* 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`(기본 admin) + MAIL_BLOCKED_ACCOUNTS.
|
||||
* 게이트웨이는 관리자 계정도 대리 접근할 수 있고, 그 세션은 **관리 권한**을 가진다(Stalwart 0.16 에서 확인).
|
||||
* 웹메일이 관리자 계정을 다루지 않게 하고, 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게
|
||||
* 메일 엔진에 묻기 **전에** 거절한다.
|
||||
*/
|
||||
readonly blockedAccounts: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 메일 엔진에 붙을 수 있는 설정 — 필수값이 모두 있는 상태 */
|
||||
export type ConfiguredMailSettings = MailSettings & {
|
||||
readonly jmapBaseUrl: string;
|
||||
readonly domain: string;
|
||||
readonly gatewayUser: string;
|
||||
readonly gatewayPassword: string;
|
||||
};
|
||||
|
||||
/** 첨부 1개 기본 한도 (MB) — 계약의 기본값 */
|
||||
export const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20;
|
||||
|
||||
/** 첨부 합계 기본 한도 (MB) — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB */
|
||||
export const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25;
|
||||
|
||||
/**
|
||||
* 메일 엔진 관리자 아이디의 기본값 — 루트 env `MAIL_ADMIN_LOCAL_PART` 로 바꾼다.
|
||||
* 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 영구 관리자를 만든다 — 두 쪽이 한 값을 읽는다.
|
||||
*
|
||||
* 옮겨 올 메일함에 같은 이름의 주소(예: 실제 직원 메일함 admin@<도메인>)가 있으면 **bootstrap 전에** 다른 아이디로 정한다.
|
||||
* 그대로 두면 엔진 관리자와 이름이 겹치고, 그 메일함은 웹메일에 로그인할 수 없다 (관리자 주소는 로그인을 막는다).
|
||||
* 다른 관리자 계정은 MAIL_BLOCKED_ACCOUNTS 로 더한다.
|
||||
*/
|
||||
export const DEFAULT_MAIL_ADMIN_LOCAL_PART = 'admin';
|
||||
|
||||
const MEGABYTE = 1024 * 1024;
|
||||
|
||||
function readTrimmed(
|
||||
read: (key: string) => string | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
const value = read(key)?.trim();
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function toOrigin(url: string | null): string | null {
|
||||
if (url === null) return null;
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
// 형식 오류는 env 검증이 기동 시점에 막는다 — 여기서는 "미설정"으로 취급한다
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** env 값을 읽어 메일 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */
|
||||
export function buildMailSettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): MailSettings {
|
||||
const attachmentMaxBytes =
|
||||
parsePositiveInt(
|
||||
read('MAIL_ATTACHMENT_MAX_SIZE_MB'),
|
||||
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
|
||||
) * MEGABYTE;
|
||||
const attachmentTotalMaxBytes =
|
||||
parsePositiveInt(
|
||||
read('MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB'),
|
||||
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB,
|
||||
) * MEGABYTE;
|
||||
|
||||
const domain = readTrimmed(read, 'MAIL_DOMAIN')?.toLowerCase() ?? null;
|
||||
// 형식(주소가 아니라 아이디만)은 env 검증이 기동 시점에 막는다
|
||||
const adminLocalPart = (
|
||||
readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART
|
||||
).toLowerCase();
|
||||
|
||||
return {
|
||||
jmapBaseUrl: toOrigin(readTrimmed(read, 'MAIL_JMAP_URL')),
|
||||
domain,
|
||||
gatewayUser: readTrimmed(read, 'MAIL_GATEWAY_USER')?.toLowerCase() ?? null,
|
||||
gatewayPassword: readTrimmed(read, 'MAIL_GATEWAY_PASSWORD'),
|
||||
attachmentMaxBytes,
|
||||
// 파일 하나 한도만 올리고 합계를 그대로 두면 큰 파일 하나를 못 보내게 된다 — 합계는 그보다 작아지지 않는다
|
||||
attachmentTotalMaxBytes: Math.max(
|
||||
attachmentTotalMaxBytes,
|
||||
attachmentMaxBytes,
|
||||
),
|
||||
blockedAccounts: new Set([
|
||||
...(domain ? [`${adminLocalPart}@${domain}`] : []),
|
||||
...parseAccountList(read('MAIL_BLOCKED_ACCOUNTS')),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 콤마로 나열한 주소 목록 → 소문자 주소 (형식 검사는 env 검증이 한다) */
|
||||
export function parseAccountList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry !== '');
|
||||
}
|
||||
|
||||
export function isMailEngineConfigured(
|
||||
settings: MailSettings,
|
||||
): settings is ConfiguredMailSettings {
|
||||
return (
|
||||
settings.jmapBaseUrl !== null &&
|
||||
settings.domain !== null &&
|
||||
settings.gatewayUser !== null &&
|
||||
settings.gatewayPassword !== null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
/**
|
||||
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
|
||||
*
|
||||
* 메일 엔진(JMAP 등)의 타입은 어댑터 밖으로 나오지 않는다. 엔진을 바꿔도 이 타입은 그대로다.
|
||||
*/
|
||||
|
||||
export type MailboxRole =
|
||||
| 'inbox'
|
||||
| 'drafts'
|
||||
| 'sent'
|
||||
| 'junk'
|
||||
| 'trash'
|
||||
| 'archive';
|
||||
|
||||
export interface MailAddress {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
readonly id: string;
|
||||
/** 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름 (표시 이름은 서비스 계층이 정한다) */
|
||||
readonly name: string;
|
||||
readonly role: MailboxRole | null;
|
||||
readonly parentId: string | null;
|
||||
readonly sortOrder: number;
|
||||
readonly totalCount: number;
|
||||
readonly unreadCount: number;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
readonly id: string;
|
||||
readonly threadId: string;
|
||||
readonly mailboxIds: string[];
|
||||
readonly from: MailAddress | null;
|
||||
readonly to: MailAddress[];
|
||||
/** 제목이 없으면 빈 문자열 */
|
||||
readonly subject: string;
|
||||
readonly preview: string;
|
||||
/** ISO 8601 UTC */
|
||||
readonly receivedAt: string;
|
||||
readonly isRead: boolean;
|
||||
readonly isFlagged: boolean;
|
||||
readonly hasAttachment: boolean;
|
||||
/** 바이트 */
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface MailAttachment {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly replyTo: MailAddress[];
|
||||
readonly sentAt: string | null;
|
||||
/** Message-ID 헤더 값 (꺾쇠 괄호 없이) */
|
||||
readonly messageId: string | null;
|
||||
readonly references: string[];
|
||||
/** 서버가 정화한 HTML — 정화 전 원본은 절대 내보내지 않는다 */
|
||||
readonly htmlBody: string | null;
|
||||
readonly textBody: string | null;
|
||||
/** 외부 이미지를 지웠으면 true */
|
||||
readonly hasBlockedImages: boolean;
|
||||
readonly attachments: MailAttachment[];
|
||||
}
|
||||
|
||||
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
|
||||
export interface InlinePart {
|
||||
readonly attachmentId: string;
|
||||
/** Content-ID (꺾쇠 괄호 없이) */
|
||||
readonly cid: string;
|
||||
readonly type: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터가 돌려주는 메일 상세 원본.
|
||||
* HTML 은 **정화 전** 값이다 — 서비스 계층이 정화한 뒤에만 MessageDetail 로 내보낸다.
|
||||
*/
|
||||
export interface MessageDetailSource extends Omit<
|
||||
MessageDetail,
|
||||
'htmlBody' | 'textBody' | 'hasBlockedImages'
|
||||
> {
|
||||
readonly rawHtmlBody: string | null;
|
||||
readonly textBody: string | null;
|
||||
readonly inlineParts: InlinePart[];
|
||||
}
|
||||
|
||||
export interface MessageListQuery {
|
||||
readonly mailboxId: string;
|
||||
readonly limit: number;
|
||||
/** 불투명 커서 — 어댑터가 만들고 해석한다 */
|
||||
readonly cursor?: string;
|
||||
/** 자유 텍스트 검색어 */
|
||||
readonly text?: string;
|
||||
}
|
||||
|
||||
export interface MessagePage {
|
||||
readonly items: MessageSummary[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
export interface MessageUpdate {
|
||||
readonly isRead?: boolean;
|
||||
readonly isFlagged?: boolean;
|
||||
/** 옮길 메일함 — 지정하면 이 메일함 하나에만 속하게 된다 */
|
||||
readonly mailboxId?: string;
|
||||
}
|
||||
|
||||
export interface OutgoingAttachment {
|
||||
/** 업로드 응답의 id */
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** 서버가 파일명 확장자로 정한 형식 (클라이언트 값이 아니다) */
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
/** 일반 텍스트 본문 */
|
||||
readonly body: string;
|
||||
readonly attachments: OutgoingAttachment[];
|
||||
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
|
||||
readonly inReplyTo?: string;
|
||||
/**
|
||||
* 중복 발송 방지(Idempotency-Key)용 Message-ID (꺾쇠 괄호 없이) — 같은 사용자·같은 키면 같은 값이다.
|
||||
* 어댑터는 이 값을 Message-ID 헤더로 쓰고, 나중에 `findSentMessage` 로 찾을 수 있어야 한다.
|
||||
*/
|
||||
readonly messageId?: string;
|
||||
/** 내용 지문 — 같은 키로 내용이 바뀌었는지 비교한다. 어댑터는 메일과 함께 남겨 `findSentMessage` 로 돌려준다 */
|
||||
readonly fingerprint?: string;
|
||||
}
|
||||
|
||||
/** 같은 Message-ID 로 이미 발송한 메일 */
|
||||
export interface SentMessageRecord {
|
||||
/** 보낸편지함의 메일 id */
|
||||
readonly id: string;
|
||||
/** 발송 때 남긴 내용 지문 — 없으면 null */
|
||||
readonly fingerprint: string | null;
|
||||
}
|
||||
|
||||
/** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */
|
||||
export interface UploadedFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly name: string;
|
||||
/** 서버가 정한 형식 */
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface AttachmentContent {
|
||||
readonly stream: Readable;
|
||||
readonly name: string;
|
||||
/** 알 수 있으면 바이트 수 */
|
||||
readonly size: number | null;
|
||||
}
|
||||
|
||||
/** 메일함 사용량 */
|
||||
export interface MailUsage {
|
||||
readonly usedBytes: number;
|
||||
/** 한도가 없으면 null */
|
||||
readonly limitBytes: number | null;
|
||||
}
|
||||
|
||||
/** 계약의 MailQuota — 사용량 + 서버의 첨부 한도 */
|
||||
export interface MailQuota extends MailUsage {
|
||||
/** 첨부 1개 최대 크기 (MAIL_ATTACHMENT_MAX_SIZE_MB) */
|
||||
readonly attachmentMaxBytes: number;
|
||||
/** 메일 한 통의 첨부 합계 한도 — 발송 검사(FILE_002)와 같은 값 (설정 한도와 엔진 한도 중 작은 값) */
|
||||
readonly attachmentTotalMaxBytes: number;
|
||||
}
|
||||
|
||||
/** 메일 계정 = 직원 계정 */
|
||||
export interface MailAccountProfile {
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { MailboxPageDto, MailQuotaDto } from './dto/mail-response.dto';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller('mailboxes')
|
||||
export class MailboxController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '메일함 목록' })
|
||||
@ApiStandardResponse(MailboxPageDto, {
|
||||
description:
|
||||
'역할 메일함(한국어 이름) → 그 외 순서. 한 번에 모두 내려준다 (hasNext=false)',
|
||||
})
|
||||
list(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.mail.listMailboxes(user);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller('mail-quota')
|
||||
export class MailQuotaController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '메일함 사용량 · 한도 · 첨부 최대 크기(1개 · 한 통 합계)',
|
||||
description:
|
||||
'한도가 없으면 limitBytes 는 null 이고, 사용량은 메일 크기 합계(근삿값, 5분 캐시)다. ' +
|
||||
'attachmentTotalMaxBytes 는 발송 때 검사하는 합계 한도와 같은 값이다.',
|
||||
})
|
||||
@ApiStandardResponse(MailQuotaDto, { description: '사용량 조회 성공' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.mail.getQuota(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Mailbox, MailboxRole } from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
|
||||
const box = (
|
||||
id: string,
|
||||
name: string,
|
||||
role: MailboxRole | null,
|
||||
sortOrder = 0,
|
||||
): Mailbox => ({
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
parentId: null,
|
||||
sortOrder,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
});
|
||||
|
||||
describe('presentMailboxes', () => {
|
||||
// 검증된 Stalwart 기본 메일함 (영어 이름, sortOrder 모두 0)
|
||||
const stalwartDefaults = [
|
||||
box('b', 'Deleted Items', 'trash'),
|
||||
box('d', 'Drafts', 'drafts'),
|
||||
box('c', 'Junk Mail', 'junk'),
|
||||
box('e', 'Sent Items', 'sent'),
|
||||
box('a', 'Inbox', 'inbox'),
|
||||
];
|
||||
|
||||
it('역할이 있으면 한국어 표준 이름을 쓰고 확정 디자인 순서로 놓는다', () => {
|
||||
const names = presentMailboxes(stalwartDefaults).map((m) => m.name);
|
||||
|
||||
expect(names).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
]);
|
||||
});
|
||||
|
||||
it('역할 메일함 다음에 그 외 메일함을 sortOrder·이름 순으로 놓는다', () => {
|
||||
const result = presentMailboxes([
|
||||
box('x', '프로젝트', null, 2),
|
||||
box('y', '거래처', null, 1),
|
||||
box('z', '가나다', null, 2),
|
||||
box('arc', 'Archive', 'archive'),
|
||||
...stalwartDefaults,
|
||||
]);
|
||||
|
||||
expect(result.map((m) => m.id)).toEqual([
|
||||
'a',
|
||||
'e',
|
||||
'd',
|
||||
'c',
|
||||
'b',
|
||||
'arc',
|
||||
'y',
|
||||
'z',
|
||||
'x',
|
||||
]);
|
||||
expect(result.find((m) => m.id === 'arc')?.name).toBe('보관함');
|
||||
expect(result.find((m) => m.id === 'x')?.name).toBe('프로젝트');
|
||||
});
|
||||
|
||||
it('원본 배열과 객체를 바꾸지 않는다', () => {
|
||||
const input = [box('a', 'Inbox', 'inbox')];
|
||||
|
||||
presentMailboxes(input);
|
||||
|
||||
expect(input[0].name).toBe('Inbox');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Mailbox, MailboxRole } from './mail.types';
|
||||
|
||||
/**
|
||||
* 메일함 표시 규칙 (순수 함수) — 계약 2절.
|
||||
* 엔진의 영어 이름("Inbox", "Sent Items" …) 대신 역할별 한국어 표준 이름을 쓰고, 정해진 순서로 놓는다.
|
||||
*/
|
||||
|
||||
const ROLE_NAMES: Readonly<Record<MailboxRole, string>> = {
|
||||
inbox: '받은편지함',
|
||||
drafts: '임시보관함',
|
||||
sent: '보낸편지함',
|
||||
junk: '스팸메일함',
|
||||
trash: '휴지통',
|
||||
archive: '보관함',
|
||||
};
|
||||
|
||||
/** 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외 (확정 디자인 기준) */
|
||||
const ROLE_ORDER: readonly MailboxRole[] = [
|
||||
'inbox',
|
||||
'sent',
|
||||
'drafts',
|
||||
'junk',
|
||||
'trash',
|
||||
'archive',
|
||||
];
|
||||
|
||||
function roleRank(role: MailboxRole | null): number {
|
||||
return role === null ? ROLE_ORDER.length : ROLE_ORDER.indexOf(role);
|
||||
}
|
||||
|
||||
function compareMailboxes(a: Mailbox, b: Mailbox): number {
|
||||
return (
|
||||
roleRank(a.role) - roleRank(b.role) ||
|
||||
a.sortOrder - b.sortOrder ||
|
||||
a.name.localeCompare(b.name, 'ko')
|
||||
);
|
||||
}
|
||||
|
||||
/** 표시 이름을 붙이고 정렬한다 (원본 배열은 바꾸지 않는다) */
|
||||
export function presentMailboxes(mailboxes: readonly Mailbox[]): Mailbox[] {
|
||||
return mailboxes
|
||||
.map((mailbox) =>
|
||||
mailbox.role ? { ...mailbox, name: ROLE_NAMES[mailbox.role] } : mailbox,
|
||||
)
|
||||
.sort(compareMailboxes);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiHeader,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} 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 { currentRequestId } from '@/shared/logger/request-context';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import {
|
||||
MessageDetailDto,
|
||||
MessagePageDto,
|
||||
MessageSummaryDto,
|
||||
SentMessageDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
|
||||
import { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { MailService } from './mail.service';
|
||||
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
|
||||
|
||||
/** 첨부는 항상 내려받게 한다 — 브라우저가 내용을 해석해 그리지 않도록 형식을 서버가 정한다 */
|
||||
const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller('messages')
|
||||
export class MessageController {
|
||||
private readonly logger = new Logger(MessageController.name);
|
||||
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '메일 목록 (커서 페이지네이션, 받은 시각 내림차순)',
|
||||
})
|
||||
@ApiStandardResponse(MessagePageDto, {
|
||||
description: '메일 목록 (total 포함)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', 'mailboxId 누락·잘못된 커서·limit 범위 초과')
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'없는 메일함 (엔진이 해석하지 못하는 mailboxId 포함)',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: MessageListQueryDto,
|
||||
) {
|
||||
return this.mail.listMessages(user, query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
|
||||
description:
|
||||
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다.',
|
||||
})
|
||||
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
get(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.getMessage(user, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)' })
|
||||
@ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
@ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동')
|
||||
update(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: UpdateMessageDto,
|
||||
) {
|
||||
return this.mail.updateMessage(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '삭제 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NO_CONTENT,
|
||||
description: '삭제(이동) 완료 — 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.mail.deleteMessage(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
|
||||
description:
|
||||
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
|
||||
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
required: false,
|
||||
description:
|
||||
'중복 발송 방지 키 (UUID) — 쓰기 화면마다 하나 만들고, 다시 보낼 때(타임아웃 뒤 재시도 등)도 같은 키를 보낸다. 형식이 틀리면 400 VAL_001',
|
||||
})
|
||||
@ApiStandardResponse(SentMessageDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description:
|
||||
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'MAIL_002',
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_005',
|
||||
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과')
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'같은 Idempotency-Key 의 발송이 처리 중(또는 엔진 장애 직후 30초) · 메일 엔진 발송 한도',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
|
||||
)
|
||||
send(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: SendMessageDto,
|
||||
@Headers(IDEMPOTENCY_KEY_HEADER) idempotencyKey?: string,
|
||||
) {
|
||||
return this.mail.sendMessage(user, dto, idempotencyKey);
|
||||
}
|
||||
|
||||
@Get(':id/attachments/:attachmentId')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '첨부 다운로드 (표준 래퍼 없음)',
|
||||
description:
|
||||
'그 메일에 실제로 있는 첨부만 내려준다. 항상 application/octet-stream + Content-Disposition: attachment.',
|
||||
})
|
||||
@ApiProduces(ATTACHMENT_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '파일',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 메일 또는 그 메일에 없는 첨부')
|
||||
async download(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Param('attachmentId', MailIdPipe) attachmentId: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const content = await this.mail.openAttachment(user, id, attachmentId);
|
||||
// 스트림 오류는 요청 처리가 끝난 뒤의 이벤트에서 올 수 있다 — 추적 ID 를 지금 잡아 둔다
|
||||
const traceId = currentRequestId() ?? 'unknown';
|
||||
|
||||
// 메일 첨부는 공유 캐시에 남기지 않는다
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
// 클라이언트가 중간에 끊으면 메일 엔진에서 받던 스트림도 닫는다 (pipe 는 원천을 닫지 않는다)
|
||||
res.once('close', () => {
|
||||
if (!content.stream.destroyed) content.stream.destroy();
|
||||
});
|
||||
|
||||
const file = new StreamableFile(content.stream, {
|
||||
type: ATTACHMENT_CONTENT_TYPE,
|
||||
disposition: buildAttachmentDisposition(content.name),
|
||||
...(content.size !== null ? { length: content.size } : {}),
|
||||
});
|
||||
// 기본 처리기는 오류 메시지를 본문으로 보낸다 — 내부 정보가 새지 않게 연결만 끊는다
|
||||
file.setErrorHandler((error) => {
|
||||
this.logger.error(
|
||||
`[MAIL_001][${traceId}] 첨부 전송 중단: ${error.message}`,
|
||||
);
|
||||
res.destroy();
|
||||
});
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
|
||||
import {
|
||||
idempotentMessageId,
|
||||
parseIdempotencyKey,
|
||||
payloadFingerprint,
|
||||
type FingerprintSource,
|
||||
} from './send-idempotency';
|
||||
|
||||
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const OTHER_KEY = '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10';
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
|
||||
const message: FingerprintSource = {
|
||||
to: [
|
||||
{ name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
{ name: null, email: 'seojun.park@arasystem.test' },
|
||||
],
|
||||
cc: [],
|
||||
bcc: [{ name: null, email: 'secret.boss@arasystem.test' }],
|
||||
subject: '메일 서버 이전 일정',
|
||||
body: '확인했습니다.\n감사합니다.',
|
||||
attachments: [
|
||||
{ id: 'blob-b', name: '일정.xlsx', type: 'application/vnd.ms-excel' },
|
||||
{ id: 'blob-a', name: '목록.pdf', type: 'application/pdf' },
|
||||
],
|
||||
inReplyTo: undefined,
|
||||
};
|
||||
|
||||
describe('parseIdempotencyKey', () => {
|
||||
it('없거나 비면 중복 방지 없이 보낸다 (key: null)', () => {
|
||||
expect(parseIdempotencyKey(undefined)).toEqual({ ok: true, key: null });
|
||||
expect(parseIdempotencyKey(' ')).toEqual({ ok: true, key: null });
|
||||
});
|
||||
|
||||
it('UUID 는 소문자로 정규화한다', () => {
|
||||
expect(parseIdempotencyKey(` ${KEY.toUpperCase()} `)).toEqual({
|
||||
ok: true,
|
||||
key: KEY,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['UUID 가 아님', 'my-key'],
|
||||
['헤더를 두 번 보냄(콤마로 합쳐짐)', `${KEY}, ${KEY}`],
|
||||
['너무 김', `${KEY}0`],
|
||||
])('형식이 틀리면 거부한다 — %s', (_label, value) => {
|
||||
expect(parseIdempotencyKey(value)).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotentMessageId', () => {
|
||||
it('같은 사용자·같은 키면 같은 값, 사용자나 키가 다르면 다른 값', () => {
|
||||
const id = idempotentMessageId(ARA, KEY);
|
||||
|
||||
expect(idempotentMessageId(' Ara.Kim@AraSystem.test ', KEY)).toBe(id);
|
||||
expect(idempotentMessageId('doyun.lee@arasystem.test', KEY)).not.toBe(id);
|
||||
expect(idempotentMessageId(ARA, OTHER_KEY)).not.toBe(id);
|
||||
});
|
||||
|
||||
it('보내는 사람의 도메인을 쓰고, 주소·키 원문은 담지 않는다', () => {
|
||||
const id = idempotentMessageId(ARA, KEY);
|
||||
|
||||
expect(id).toMatch(/^ara\.[0-9a-f]{32}@arasystem\.test$/);
|
||||
expect(id).not.toContain('ara.kim');
|
||||
expect(id).not.toContain(KEY);
|
||||
});
|
||||
|
||||
it('전제: 토큰은 SHA-256(주소 + 줄바꿈 + 키)의 앞 128비트뿐이다 — 키(122비트 무작위)를 거꾸로 알아낼 수 없다', () => {
|
||||
// 받는 사람이 보는 값이 키의 단방향 해시뿐이라는 것을 고정한다.
|
||||
// 이 전제가 깨지면(키·키의 일부가 그대로 실리면) 지문 HMAC 키까지 드러난다 (send-idempotency.ts 머리말)
|
||||
const expected = createHash('sha256')
|
||||
.update(`${ARA}\n${KEY}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
|
||||
expect(idempotentMessageId(ARA, KEY)).toBe(
|
||||
`ara.${expected}@arasystem.test`,
|
||||
);
|
||||
for (const part of KEY.split('-')) {
|
||||
expect(idempotentMessageId(ARA, KEY)).not.toContain(part);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
|
||||
it('같은 키·같은 내용이면 같은 지문 — 주소 순서·대소문자·표시 이름·첨부 순서·줄바꿈 형식은 무시한다', () => {
|
||||
const reordered: FingerprintSource = {
|
||||
...message,
|
||||
to: [
|
||||
{ name: '박서준', email: 'SEOJUN.PARK@arasystem.test' },
|
||||
{ name: null, email: 'doyun.lee@arasystem.test' },
|
||||
],
|
||||
body: '확인했습니다.\r\n감사합니다.',
|
||||
attachments: [...message.attachments].reverse(),
|
||||
};
|
||||
|
||||
expect(payloadFingerprint(reordered, KEY)).toBe(
|
||||
payloadFingerprint(message, KEY),
|
||||
);
|
||||
});
|
||||
|
||||
const changes: [string, Partial<FingerprintSource>][] = [
|
||||
['받는 사람', { to: [{ name: null, email: 'other@arasystem.test' }] }],
|
||||
['참조', { cc: [{ name: null, email: 'cc@arasystem.test' }] }],
|
||||
['숨은 참조', { bcc: [] }],
|
||||
['제목', { subject: '바뀐 제목' }],
|
||||
['본문', { body: '고친 본문' }],
|
||||
['첨부', { attachments: [message.attachments[0]] }],
|
||||
['답장 대상', { inReplyTo: 'eaaaaab' }],
|
||||
];
|
||||
|
||||
it.each(changes)('%s 이 바뀌면 지문이 달라진다', (_label, change) => {
|
||||
expect(payloadFingerprint({ ...message, ...change }, KEY)).not.toBe(
|
||||
payloadFingerprint(message, KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('형식은 v2:<HMAC-SHA256 16진 64자>', () => {
|
||||
expect(payloadFingerprint(message, KEY)).toMatch(/^v2:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('키가 다르면 같은 내용이라도 지문이 다르다 — 메일끼리 지문을 비교할 수 없다', () => {
|
||||
expect(payloadFingerprint(message, OTHER_KEY)).not.toBe(
|
||||
payloadFingerprint(message, KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('키를 모르면 맞춰 볼 수 없다 — 내용만으로 만든 해시·서버 비밀 HMAC 과 일치하지 않는다', () => {
|
||||
const fingerprint = payloadFingerprint(message, KEY);
|
||||
const canonicalGuess = JSON.stringify([
|
||||
'v2',
|
||||
['doyun.lee@arasystem.test', 'seojun.park@arasystem.test'],
|
||||
[],
|
||||
['secret.boss@arasystem.test'],
|
||||
message.subject,
|
||||
message.body,
|
||||
['blob-a', 'blob-b'],
|
||||
null,
|
||||
]);
|
||||
|
||||
// 받은 사람이 숨은 참조 후보를 넣어 맞춰 보는 시도 — 키 없이는 같은 값이 나오지 않는다
|
||||
expect(fingerprint).not.toBe(
|
||||
`v2:${createHash('sha256').update(canonicalGuess).digest('hex')}`,
|
||||
);
|
||||
// 서버 비밀(예: JWT_SECRET)로 만든 HMAC 도 아니다 — 공개 값이 서버 비밀을 대입해 볼 수단이 되지 않는다
|
||||
expect(fingerprint).not.toBe(
|
||||
`v2:${createHmac('sha256', 'unit-test-secret-unit-test-secret-1234').update(canonicalGuess).digest('hex')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash, createHmac, hkdfSync } from 'crypto';
|
||||
|
||||
import type { MailAddress, OutgoingMessage } from './mail.types';
|
||||
|
||||
/**
|
||||
* 발송 중복 방지 (계약 2절 "중복 발송 방지 — Idempotency-Key") — 순수 함수 모음.
|
||||
*
|
||||
* - 같은 사용자·같은 키 → 같은 Message-ID. 재시도는 이 Message-ID 로 이미 보낸 메일을 찾는다
|
||||
* - 첫 발송 때 내용 지문을 메일 헤더로 남기고, 같은 키의 재시도와 비교한다 (다르면 MAIL_005)
|
||||
*
|
||||
* ── 받는 사람에게 보이는 값과 그 안전성 ─────────────────────────────────────────
|
||||
* Message-ID 와 지문 헤더는 메일과 함께 (외부) 받는 사람에게 간다. 둘 다 **Idempotency-Key 를 모르면
|
||||
* 쓸모가 없게** 만든다 — 키는 클라이언트가 쓰기 화면마다 만드는 무작위 UUID v4(122비트)다.
|
||||
* - Message-ID 토큰 = SHA-256(주소 + "\n" + 키) 의 앞 128비트. 키를 거꾸로 알아내려면 122비트 무작위 값을
|
||||
* SHA-256 으로 대입해야 하므로 할 수 없다 (전제: 키가 무작위다 — 형식만 검사하므로 클라이언트 책임)
|
||||
* - 지문 = HMAC-SHA256(HKDF(키, 용도 라벨), 정규화한 내용). 받은 사람은 내용 대부분(받는 사람·제목·본문)을 알지만
|
||||
* 키를 모르므로 숨은 참조 후보를 넣어 맞춰 볼 수 없고, 다른 메일의 지문과 비교할 수도 없다
|
||||
* - 서버 비밀(JWT_SECRET 등)은 쓰지 않는다 — 공개되는 값이 서버 비밀을 대입해 볼 수단이 되지 않게
|
||||
*/
|
||||
|
||||
/** 요청 헤더 이름 (Express 는 소문자로 읽는다) */
|
||||
export const IDEMPOTENCY_KEY_HEADER = 'idempotency-key';
|
||||
|
||||
/** 키 형식 — 클라이언트는 쓰기 화면마다 무작위 UUID 를 만든다 (계약) */
|
||||
const IDEMPOTENCY_KEY_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Message-ID 에 넣는 토큰 길이 (16진 32자 = 128비트) */
|
||||
const MESSAGE_ID_TOKEN_LENGTH = 32;
|
||||
|
||||
/** 지문 형식 버전 — 키·정규화 규칙을 바꾸면 올린다 (옛 지문과 섞여도 오판하지 않게) */
|
||||
const FINGERPRINT_VERSION = 'v2';
|
||||
|
||||
/** 지문 HMAC 키를 Idempotency-Key 에서 유도할 때의 용도 라벨 (HKDF info) — 키를 다른 용도의 값과 섞지 않는다 */
|
||||
const FINGERPRINT_KEY_INFO = 'arasystem/mail-payload-fingerprint/v2';
|
||||
const FINGERPRINT_KEY_BYTES = 32;
|
||||
|
||||
export type IdempotencyKeyParseResult =
|
||||
| { readonly ok: true; readonly key: string | null }
|
||||
| { readonly ok: false };
|
||||
|
||||
/** 헤더 값을 읽는다 — 없으면 key: null(중복 방지 없이 보낸다), 형식이 틀리면 ok: false(VAL_001) */
|
||||
export function parseIdempotencyKey(
|
||||
raw: string | undefined,
|
||||
): IdempotencyKeyParseResult {
|
||||
if (raw === undefined || raw.trim() === '') return { ok: true, key: null };
|
||||
|
||||
const key = raw.trim().toLowerCase();
|
||||
return IDEMPOTENCY_KEY_PATTERN.test(key) ? { ok: true, key } : { ok: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* (사용자, 키) → Message-ID (꺾쇠 괄호 없이). 받는 사람에게도 보이지만
|
||||
* 무작위 키를 모르면 거꾸로 알아낼 수 없다 (파일 머리말) — 해시라 사용자 주소도 드러나지 않는다.
|
||||
*/
|
||||
export function idempotentMessageId(user: string, key: string): string {
|
||||
const address = user.trim().toLowerCase();
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1);
|
||||
const token = createHash('sha256')
|
||||
.update(`${address}\n${key}`)
|
||||
.digest('hex')
|
||||
.slice(0, MESSAGE_ID_TOKEN_LENGTH);
|
||||
return `ara.${token}@${domain}`;
|
||||
}
|
||||
|
||||
/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·첨부 id·답장 대상 */
|
||||
export type FingerprintSource = Pick<
|
||||
OutgoingMessage,
|
||||
'to' | 'cc' | 'bcc' | 'subject' | 'body' | 'attachments' | 'inReplyTo'
|
||||
>;
|
||||
|
||||
function addresses(list: readonly MailAddress[]): string[] {
|
||||
return list.map((address) => address.email.trim().toLowerCase()).sort();
|
||||
}
|
||||
|
||||
/** 지문 HMAC 키 — Idempotency-Key 에서 용도를 나눠 유도한다 */
|
||||
function fingerprintKey(idempotencyKey: string): Buffer {
|
||||
return Buffer.from(
|
||||
hkdfSync(
|
||||
'sha256',
|
||||
idempotencyKey,
|
||||
Buffer.alloc(0),
|
||||
FINGERPRINT_KEY_INFO,
|
||||
FINGERPRINT_KEY_BYTES,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내용 지문 — `v2:` + HMAC-SHA256(HKDF(Idempotency-Key), 정규화한 내용).
|
||||
* 그냥 해시를 쓰면 받은 사람이 숨은 참조 후보를 넣어 맞춰 볼 수 있다 — 키를 모르면 맞춰 볼 수 없다 (파일 머리말).
|
||||
*
|
||||
* 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬.
|
||||
*/
|
||||
export function payloadFingerprint(
|
||||
message: FingerprintSource,
|
||||
idempotencyKey: string,
|
||||
): string {
|
||||
const canonical = JSON.stringify([
|
||||
FINGERPRINT_VERSION,
|
||||
addresses(message.to),
|
||||
addresses(message.cc),
|
||||
addresses(message.bcc),
|
||||
message.subject,
|
||||
message.body.replace(/\r\n?/g, '\n'),
|
||||
message.attachments.map((attachment) => attachment.id).sort(),
|
||||
message.inReplyTo ?? null,
|
||||
]);
|
||||
const digest = createHmac('sha256', fingerprintKey(idempotencyKey))
|
||||
.update(canonical)
|
||||
.digest('hex');
|
||||
return `${FINGERPRINT_VERSION}:${digest}`;
|
||||
}
|
||||
@@ -2,22 +2,34 @@ import {
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { UploadResponseDto } from './dto/upload-response.dto';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
|
||||
// 로그인한 직원만 올린다 — 가드가 인터셉터(multer)보다 먼저 돌아 미인증 요청은 디스크에 닿기 전에 막힌다.
|
||||
@ApiTags('Upload')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('files')
|
||||
export class UploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
@@ -36,6 +36,8 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
files: 1,
|
||||
fields: 10,
|
||||
},
|
||||
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름(originalName)이 깨진다
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!isAllowedFile(file.originalname, file.mimetype)) {
|
||||
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
@@ -19,8 +19,8 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UserResponseDto } from './dto/user-response.dto';
|
||||
|
||||
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
|
||||
// (인증 없는 예제다. 보호가 필요하면 @UseGuards(JwtAuthGuard) + @ApiCookieAuth() — 인증은 쿠키뿐이다)
|
||||
@ApiTags('User')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
Reference in New Issue
Block a user