generated from ttipo/project-base
feat(backend): 임시 저장·서명(그림)·받는 사람 추천 API와 MAIL_008
- /api/drafts: 발송과 같은 준비(정화·한도)로 초안을 만들고, 바꿔 저장은 새로 만들어 다시 읽은 뒤 옛것을 지운다. 만들기와 지우기를 한 요청에 넣지 않는다 — 엔진이 만들기가 실패해도 지우기를 해서 저장본이 사라졌다. 옛 초안이 이미 없으면 409 MAIL_008(새 코드), 초안이 아닌 id 는 404 RES_001 로 아무것도 지우지 않는다. - 사용자 메일의 영구 삭제(교체한 옛 초안 · 보낸 뒤 draftId · 휴지통 영구 삭제)는 대상인지 확인한 상태 그대로일 때만 ifInState 로 지운다 — 확인과 지우기 사이에 다른 탭이 옮긴 메일까지 지우지 않게 (Stalwart 0.16.27 에서 확인) - /api/mail-signature: 서명(DB)과 그림(업로드 볼륨). 그림은 확장자·MIME·매직 바이트·크기·가로세로를 보고, 보낼 때 multipart/related 의 cid 그림으로 싣는다. 서명 저장은 사용자별로 잠가 차례로 한다 - /api/recipient-suggestions: 로그인한 직원 기록(mail_directory)과 내 메일함의 최근 주소로 추천, 사용자별 10분 캐시 - 본문 그림은 첨부 표시에서 뺀다(hasAttachment 직접 계산 · isInline), 예외 필터 로그의 검색어(q)는 길이만 남긴다 - 마이그레이션 AddMailSignaturesAndDirectory: 새 표 3개만 만든다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,32 @@
|
||||
import { isSendMessageRequest } from './app.setup';
|
||||
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
|
||||
|
||||
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/drafts'],
|
||||
['POST', '/API/Drafts/'],
|
||||
['PUT', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/?x=1'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts'],
|
||||
['POST', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/attachments'],
|
||||
['PATCH', '/api/drafts/faaaaabi'],
|
||||
['POST', '/api/draftsx'],
|
||||
['PUT', '/drafts/faaaaabi'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(false);
|
||||
}
|
||||
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
|
||||
+30
-10
@@ -8,37 +8,57 @@ import type { IncomingMessage } from 'http';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
|
||||
|
||||
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
|
||||
export const API_PREFIX = 'api';
|
||||
|
||||
/**
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)을 뺀 모든 API.
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API.
|
||||
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
|
||||
*/
|
||||
export const JSON_BODY_LIMIT = '1mb';
|
||||
|
||||
/**
|
||||
* 발송 요청(`POST /api/messages`)의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
|
||||
* 앞단 nginx 도 이 경로만 4m 으로 둔다 (release-ops). 넘으면 413 FILE_002.
|
||||
* 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다.
|
||||
* 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002.
|
||||
*/
|
||||
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
|
||||
|
||||
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
|
||||
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
|
||||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다: Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === SEND_MESSAGE_PATH;
|
||||
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
|
||||
*/
|
||||
export function isDraftSaveRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
const path = routedPath(req);
|
||||
if (req.method === 'POST') return path === DRAFTS_PATH;
|
||||
if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) {
|
||||
return false;
|
||||
}
|
||||
const id = path.slice(DRAFTS_PATH.length + 1);
|
||||
return id !== '' && !id.includes('/');
|
||||
}
|
||||
|
||||
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
|
||||
@@ -81,14 +101,14 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
|
||||
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
|
||||
//
|
||||
// 경로별 한도: 발송 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
|
||||
app.useBodyParser('json', {
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
isSendMessageRequest(req) && isJsonBody(req),
|
||||
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
|
||||
});
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
|
||||
},
|
||||
// 임시 저장본 교체(PUT /api/drafts/{id}) 때 옛 초안이 이미 없다 — 다른 창·기기에서 먼저 고쳤거나 지웠다. 아무것도 저장하지 않는다.
|
||||
// 클라이언트는 막지 않고 지금 내용을 새 임시 저장본(POST)으로 남긴다 (계약 2절 "임시 저장")
|
||||
MAIL_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -21,7 +21,11 @@ interface CapturedResponse {
|
||||
}
|
||||
|
||||
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
|
||||
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
function runFilter(
|
||||
exception: unknown,
|
||||
requestId?: string,
|
||||
url = '/api/users/1',
|
||||
): CapturedResponse {
|
||||
const captured = {} as CapturedResponse;
|
||||
|
||||
const response = {
|
||||
@@ -37,7 +41,7 @@ function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: '/api/users/1',
|
||||
url,
|
||||
header: () => requestId,
|
||||
};
|
||||
|
||||
@@ -152,6 +156,22 @@ describe('HttpExceptionFilter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('로그의 요청 주소에서 검색어(q)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
|
||||
const warnLog = jest.spyOn(Logger.prototype, 'warn');
|
||||
warnLog.mockClear();
|
||||
|
||||
runFilter(
|
||||
new BusinessException('VAL_001'),
|
||||
'trace-q',
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&cursor=bad',
|
||||
);
|
||||
|
||||
const logged = warnLog.mock.calls.map(([message]) => String(message));
|
||||
expect(logged).toEqual([
|
||||
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&cursor=bad',
|
||||
]);
|
||||
});
|
||||
|
||||
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
|
||||
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveCodeByStatus,
|
||||
} from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { maskUrlForLog } from '@/shared/utils/mask';
|
||||
|
||||
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
|
||||
const SERVER_ERROR_STATUS_THRESHOLD = 500;
|
||||
@@ -140,7 +141,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
request: Request,
|
||||
traceId?: string,
|
||||
): void {
|
||||
const where = `${request.method} ${request.url}`;
|
||||
// 검색어(q)는 길이만 — 사람 이름·주소가 든다 (shared/utils/mask.ts)
|
||||
const where = `${request.method} ${maskUrlForLog(request.url)}`;
|
||||
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
|
||||
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
|
||||
const context =
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 서명·서명 그림·사내 목록 표 (docs/mail/api-contract.md 2절 "서명" · "받는 사람 추천").
|
||||
* - mail_signatures: 사용자마다 서명 하나 (정화한 HTML·텍스트·넣을 메일)
|
||||
* - mail_signature_images: 서명 그림 정보 — 파일은 업로드 볼륨의 mail-signatures 폴더. 주인 조회용 인덱스(user_email),
|
||||
* 정리 작업용 부분 인덱스(attached_at 이 비어 있는 행의 created_at)
|
||||
* - mail_directory: 웹메일 로그인에 성공한 직원 (받는 사람 추천의 "사내")
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-signature/*.entity.ts · modules/mail-directory/mail-directory.entity.ts)
|
||||
*/
|
||||
export class AddMailSignaturesAndDirectory1793084109734 implements MigrationInterface {
|
||||
name = 'AddMailSignaturesAndDirectory1793084109734';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_directory" ("email" character varying(254) NOT NULL, "name" character varying(200) NOT NULL, "last_login_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_d79c6055eed1d875a53a13a71f8" PRIMARY KEY ("email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signature_images" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "content_type" character varying(32) NOT NULL, "size" integer NOT NULL, "width" integer NOT NULL, "height" integer NOT NULL, "file_name" character varying(64) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "attached_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_f52f2c8dbb00f2973088763a776" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_unattached" ON "mail_signature_images" ("created_at") WHERE "attached_at" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_user_email" ON "mail_signature_images" ("user_email") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signatures" ("user_email" character varying(254) NOT NULL, "body_html" text, "body_text" text, "is_for_new" boolean NOT NULL DEFAULT true, "is_for_reply" boolean NOT NULL DEFAULT true, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "mail_signatures"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_unattached"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_signature_images"`);
|
||||
await queryRunner.query(`DROP TABLE "mail_directory"`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
@@ -29,6 +30,8 @@ import { LoginAttemptService } from './login-attempt.service';
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([AuthSessionEntity]),
|
||||
MailModule,
|
||||
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
|
||||
MailDirectoryModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { hashRefreshToken } from './auth-session.policy';
|
||||
import type {
|
||||
@@ -135,6 +136,7 @@ describe('AuthService', () => {
|
||||
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
|
||||
let attempts: LoginAttemptService;
|
||||
let sessions: FakeSessions;
|
||||
let directory: { recordLogin: jest.Mock };
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -147,6 +149,7 @@ describe('AuthService', () => {
|
||||
jwt,
|
||||
attempts,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
directory as unknown as MailDirectoryService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -184,6 +187,7 @@ describe('AuthService', () => {
|
||||
};
|
||||
attempts = new LoginAttemptService();
|
||||
sessions = new FakeSessions();
|
||||
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -235,6 +239,31 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천) — 실패한 로그인은 기록하지 않는다', async () => {
|
||||
await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
expect(directory.recordLogin).toHaveBeenCalledWith({
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
});
|
||||
|
||||
directory.recordLogin.mockClear();
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
|
||||
directory.recordLogin.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
expect(result.user.email).toBe(ARA);
|
||||
expect(logs.some((line) => line.includes('사내 목록'))).toBe(true);
|
||||
expect(logs.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
@@ -73,6 +74,7 @@ export class AuthService {
|
||||
private readonly jwt: JwtService,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
private readonly directory: MailDirectoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -115,10 +117,29 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile);
|
||||
await this.recordInDirectory(profile);
|
||||
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
|
||||
return { user: toUser(profile), tokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인한 직원을 사내 목록에 기록한다 (받는 사람 추천 — 계약 2절). 관리자·게이트웨이·막은 계정은 넣지 않는다.
|
||||
* 기록하지 못해도 로그인은 그대로 성공한다 — 추천에서 빠질 뿐이다.
|
||||
*/
|
||||
private async recordInDirectory(profile: MailAccountProfile): Promise<void> {
|
||||
if (!this.isLoginAddress(profile.email)) return;
|
||||
try {
|
||||
await this.directory.recordLogin({
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 사내 목록에 로그인한 직원을 기록하지 못했습니다: user=${maskEmail(profile.email)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 표시 이름 최대 길이 — mail.constants 의 MAX_DISPLAY_NAME_LENGTH 와 같다 */
|
||||
export const DIRECTORY_NAME_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
|
||||
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
|
||||
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
|
||||
*/
|
||||
@Entity({ name: 'mail_directory' })
|
||||
export class MailDirectoryEntryEntity {
|
||||
/** 정규화한(소문자) 직원 주소 */
|
||||
@PrimaryColumn({ type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
email!: string;
|
||||
|
||||
/** 마지막으로 로그인했을 때 메일 엔진이 알려 준 표시 이름 */
|
||||
@Column({ type: 'varchar', length: DIRECTORY_NAME_LENGTH })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz' })
|
||||
lastLoginAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import { MailDirectoryService } from './mail-directory.service';
|
||||
|
||||
/**
|
||||
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
|
||||
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MailDirectoryEntryEntity])],
|
||||
providers: [MailDirectoryService],
|
||||
exports: [MailDirectoryService],
|
||||
})
|
||||
export class MailDirectoryModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import {
|
||||
MAIL_DIRECTORY_ACTIVE_DAYS,
|
||||
MAX_DIRECTORY_CONTACTS,
|
||||
MailDirectoryService,
|
||||
} from './mail-directory.service';
|
||||
|
||||
describe('MailDirectoryService (사내 목록)', () => {
|
||||
let upsert: jest.Mock;
|
||||
let find: jest.Mock;
|
||||
let service: MailDirectoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
upsert = jest.fn().mockResolvedValue(undefined);
|
||||
find = jest.fn().mockResolvedValue([]);
|
||||
service = new MailDirectoryService({
|
||||
upsert,
|
||||
find,
|
||||
} as unknown as Repository<MailDirectoryEntryEntity>);
|
||||
});
|
||||
|
||||
it('로그인한 직원을 주소 하나에 한 줄로 넣거나 고친다 — 주소는 소문자, 이름은 앞뒤 공백 없이 200자까지', async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
|
||||
await service.recordLogin(
|
||||
{ email: ' Doyun.Lee@ARASYSTEM.test ', name: ` ${'가'.repeat(250)} ` },
|
||||
now,
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '가'.repeat(200),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
});
|
||||
|
||||
it(`최근 ${MAIL_DIRECTORY_ACTIVE_DAYS}일 안에 로그인한 직원만, 최근에 로그인한 사람부터 상한까지 주소·이름만 읽는다 (퇴사자는 추천하지 않는다)`, async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
find.mockResolvedValue([
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '이도윤',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.listContacts(MAX_DIRECTORY_CONTACTS, now),
|
||||
).resolves.toEqual([{ email: 'doyun.lee@arasystem.test', name: '이도윤' }]);
|
||||
expect(find).toHaveBeenCalledWith({
|
||||
// 2026-10-27 의 90일 전 = 2026-07-29
|
||||
where: { lastLoginAt: MoreThanOrEqual(new Date('2026-07-29T03:00:00Z')) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: MAX_DIRECTORY_CONTACTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { DirectoryContact } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
DIRECTORY_NAME_LENGTH,
|
||||
MailDirectoryEntryEntity,
|
||||
} from './mail-directory.entity';
|
||||
|
||||
/** 추천에 읽어 오는 사내 목록 상한 — 최근에 로그인한 사람부터 (추천 목록 자체는 1,000개까지다) */
|
||||
export const MAX_DIRECTORY_CONTACTS = 2000;
|
||||
|
||||
/**
|
||||
* 이 기간 안에 로그인한 직원만 "사내"로 추천한다 — 퇴사자가 계속 사내 맨 앞에 나와 반송되지 않게.
|
||||
* 웹메일을 쓰는 직원은 세션이 길어야 30일이라 그 안에 다시 로그인한다. 표에서 지우지는 않는다 (다시 로그인하면 돌아온다).
|
||||
*/
|
||||
export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
|
||||
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailDirectoryService {
|
||||
constructor(
|
||||
@InjectRepository(MailDirectoryEntryEntity)
|
||||
private readonly entries: Repository<MailDirectoryEntryEntity>,
|
||||
) {}
|
||||
|
||||
/** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */
|
||||
async recordLogin(
|
||||
contact: DirectoryContact,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
await this.entries.upsert(
|
||||
{
|
||||
email: contact.email.trim().toLowerCase(),
|
||||
name: contact.name.trim().slice(0, DIRECTORY_NAME_LENGTH),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
}
|
||||
|
||||
/** 최근 MAIL_DIRECTORY_ACTIVE_DAYS 일 안에 로그인한 직원 — 최근에 로그인한 사람부터 */
|
||||
async listContacts(
|
||||
limit: number = MAX_DIRECTORY_CONTACTS,
|
||||
now: Date = new Date(),
|
||||
): Promise<DirectoryContact[]> {
|
||||
const activeSince = new Date(
|
||||
now.getTime() - MAIL_DIRECTORY_ACTIVE_DAYS * DAY_MS,
|
||||
);
|
||||
const rows = await this.entries.find({
|
||||
where: { lastLoginAt: MoreThanOrEqual(activeSince) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
return rows.map(({ email, name }) => ({ email, name }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type {
|
||||
MailSignature,
|
||||
SignatureImage,
|
||||
} from '@/modules/mail-signature/mail-signature.types';
|
||||
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
|
||||
|
||||
const IMAGE_TYPES: SignatureImageType[] = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/** PUT /api/mail-signature — 계약 2절 "서명" */
|
||||
export class UpdateMailSignatureDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
|
||||
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
|
||||
bodyHtml?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '김아라 · 아라시스템',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
|
||||
body?: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다', example: true })
|
||||
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
|
||||
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForReply!: boolean;
|
||||
}
|
||||
|
||||
export class SignatureImageDto implements SignatureImage {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그림 id — 서명 HTML 의 <img src="/api/mail-signature/images/{id}"> 에 쓴다',
|
||||
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 내용으로 확인한 형식',
|
||||
enum: IMAGE_TYPES,
|
||||
example: 'image/png',
|
||||
})
|
||||
type!: SignatureImageType;
|
||||
|
||||
@ApiProperty({
|
||||
description: `크기 (바이트, ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')} 이하)`,
|
||||
example: 18342,
|
||||
})
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 가로 픽셀', example: 480 })
|
||||
width!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 세로 픽셀', example: 120 })
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class MailSignatureDto implements MailSignature {
|
||||
@ApiProperty({
|
||||
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
body!: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
|
||||
example: true,
|
||||
})
|
||||
isForReply!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
|
||||
type: [SignatureImageDto],
|
||||
})
|
||||
images!: SignatureImageDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-27T05:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 'image/jpeg' 등 */
|
||||
const CONTENT_TYPE_LENGTH = 32;
|
||||
/** 서버가 만든 파일 이름 — `<uuid>.<확장자>` */
|
||||
const FILE_NAME_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 서명 그림 (계약 2절 "서명") — 파일은 업로드 볼륨의 하위 폴더에 두고(mail-signature.constants.ts), 여기에는 정보만 둔다.
|
||||
* - 파일 이름은 서버가 만든다 (UUID + 내용으로 확인한 형식의 확장자). 올린 이름은 저장하지 않는다
|
||||
* - `attached_at`: 서명을 저장할 때 그 HTML 이 가리키면 채운다. 비어 있고 하루가 지난 그림은 정리 작업이 지운다
|
||||
*/
|
||||
@Entity({ name: 'mail_signature_images' })
|
||||
@Index('IDX_mail_signature_images_user_email', ['userEmail'])
|
||||
@Index('IDX_mail_signature_images_unattached', ['createdAt'], {
|
||||
where: '"attached_at" IS NULL',
|
||||
})
|
||||
export class MailSignatureImageEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 파일 내용으로 확인한 형식 (클라이언트가 보낸 값이 아니다) */
|
||||
@Column({
|
||||
name: 'content_type',
|
||||
type: 'varchar',
|
||||
length: CONTENT_TYPE_LENGTH,
|
||||
})
|
||||
contentType!: string;
|
||||
|
||||
/** 바이트 */
|
||||
@Column({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
/** 원본 픽셀 — 파일 머리에서 읽었다 */
|
||||
@Column({ type: 'integer' })
|
||||
width!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
height!: number;
|
||||
|
||||
@Column({ name: 'file_name', type: 'varchar', length: FILE_NAME_LENGTH })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 저장한 서명이 처음 가리킨 시각 — null 이면 올리기만 한 그림 */
|
||||
@Column({ name: 'attached_at', type: 'timestamptz', nullable: true })
|
||||
attachedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import type { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NOW = new Date('2026-10-27T04:00:00Z');
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const idOf = (n: number) => `0000000${n}-0000-4000-8000-000000000000`;
|
||||
|
||||
describe('MailSignatureImageCleanup (하루 한 번 서명 그림 정리)', () => {
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
let repository: {
|
||||
findUnattachedBefore: jest.Mock<
|
||||
Promise<MailSignatureImageEntity[]>,
|
||||
[Date]
|
||||
>;
|
||||
deleteUnattachedImages: jest.Mock<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>;
|
||||
listFileNames: jest.Mock<Promise<Set<string>>, []>;
|
||||
};
|
||||
let cleanup: MailSignatureImageCleanup;
|
||||
|
||||
/** 파일을 만들고 수정 시각을 `hoursAgo` 시간 전으로 둔다 */
|
||||
function file(n: number, hoursAgo: number): string {
|
||||
const fileName = `${idOf(n)}.png`;
|
||||
const path = join(dir, fileName);
|
||||
writeFileSync(path, 'png');
|
||||
const at = new Date(NOW.getTime() - hoursAgo * HOUR);
|
||||
utimesSync(path, at, at);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-cleanup-'));
|
||||
storage = new SignatureImageStorage(dir);
|
||||
repository = {
|
||||
findUnattachedBefore: jest
|
||||
.fn<Promise<MailSignatureImageEntity[]>, [Date]>()
|
||||
.mockResolvedValue([]),
|
||||
deleteUnattachedImages: jest
|
||||
.fn<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>()
|
||||
.mockImplementation((ids) =>
|
||||
Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))),
|
||||
),
|
||||
listFileNames: jest
|
||||
.fn<Promise<Set<string>>, []>()
|
||||
.mockResolvedValue(new Set()),
|
||||
};
|
||||
cleanup = new MailSignatureImageCleanup(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('서명에 붙지 않은 채 하루가 지난 그림을 표와 파일에서 지운다', async () => {
|
||||
const stale = file(1, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stale } as MailSignatureImageEntity,
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set());
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.findUnattachedBefore).toHaveBeenCalledWith(
|
||||
new Date(NOW.getTime() - 24 * HOUR),
|
||||
);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([idOf(1)]);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('표에 없는 파일은 하루가 지났을 때만 지운다 — 표에 있는 파일·막 쓴 파일·다른 이름의 파일은 그대로', async () => {
|
||||
const known = file(1, 48);
|
||||
const orphan = file(2, 25);
|
||||
const fresh = file(3, 1);
|
||||
writeFileSync(join(dir, 'README.txt'), '그림이 아니다');
|
||||
repository.listFileNames.mockResolvedValue(new Set([known]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
const left = (await storage.list()).map((stored) => stored.fileName).sort();
|
||||
expect(left).toEqual([known, fresh].sort());
|
||||
expect(left).not.toContain(orphan);
|
||||
});
|
||||
|
||||
it('찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다', async () => {
|
||||
const stillUnattached = file(1, 30);
|
||||
const attachedMeanwhile = file(2, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached } as MailSignatureImageEntity,
|
||||
{ id: idOf(2), fileName: attachedMeanwhile } as MailSignatureImageEntity,
|
||||
]);
|
||||
// 저장소는 지우는 순간 attached_at IS NULL 을 다시 본다 — 2번은 그사이 붙어 지우지 않았다
|
||||
repository.deleteUnattachedImages.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached },
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set([attachedMeanwhile]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([
|
||||
idOf(1),
|
||||
idOf(2),
|
||||
]);
|
||||
expect((await storage.list()).map((stored) => stored.fileName)).toEqual([
|
||||
attachedMeanwhile,
|
||||
]);
|
||||
});
|
||||
|
||||
it('정리에 실패해도 던지지 않는다 — 오류 로그를 남기고 다음 주기에 다시 한다', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
repository.findUnattachedBefore.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[SYS_001] 서명 그림 정리에 실패했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('지울 것이 없으면 아무것도 지우지 않는다 (폴더가 아직 없어도)', async () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS } from './mail-signature.constants';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 서명 그림 정리 — 하루에 한 번 (auth-session.cleanup.ts 와 같은 모양).
|
||||
* - 올리고 서명에 저장하지 않은 채 하루가 지난 그림을 지운다 (표와 파일)
|
||||
* - 표에 없는 파일(올리다 끊긴 것 등)도 하루가 지났으면 지운다
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureImageCleanup {
|
||||
private readonly logger = new Logger(MailSignatureImageCleanup.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, {
|
||||
name: 'mail-signature-image-cleanup',
|
||||
})
|
||||
async purgeUnattachedImages(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const before = new Date(
|
||||
now.getTime() - UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS,
|
||||
);
|
||||
|
||||
// 찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다
|
||||
const unattached = await this.repository.findUnattachedBefore(before);
|
||||
const deleted = await this.repository.deleteUnattachedImages(
|
||||
unattached.map((image) => image.id),
|
||||
);
|
||||
for (const image of deleted) await this.storage.remove(image.fileName);
|
||||
|
||||
const known = await this.repository.listFileNames();
|
||||
const orphans = (await this.storage.list()).filter(
|
||||
(file) => !known.has(file.fileName) && file.modifiedAt < before,
|
||||
);
|
||||
for (const file of orphans) await this.storage.remove(file.fileName);
|
||||
|
||||
const removed = deleted.length + orphans.length;
|
||||
// 반복되는 정상 작업이라 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,57 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { INLINE_IMAGE_MAX_BYTES } from '@/modules/mail/html/inline-images';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
|
||||
/**
|
||||
* 서명 한도 — 계약 2절 "서명"의 숫자를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 서명 그림 한 장 최대 크기 — 받은 메일의 내장 그림을 본문에 넣는 한도와 **같은 값**이다.
|
||||
* 더 크게 받으면 보낸 메일을 우리 웹메일로 다시 읽을 때(보낸편지함·받는 사람) 그림이 빠진다 (html/inline-images.ts).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* 서명 그림의 가로·세로 상한(픽셀, 각각) — 넘으면 올리기가 400 FILE_001.
|
||||
* 300KB 안에도 수만×수만 픽셀(압축 폭탄)이 들 수 있고, PNG 머리의 가로·세로(32비트)는 표의 integer 열을 넘을 수 있다.
|
||||
* 서명에 넣는 폭은 600px 까지라(보내는 정화의 width) 충분히 넉넉하다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
|
||||
|
||||
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
|
||||
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
|
||||
|
||||
/**
|
||||
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
|
||||
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
|
||||
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGES_STORED_MAX = 10;
|
||||
|
||||
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
|
||||
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
|
||||
|
||||
/** 텍스트 서명 최대 길이 — JS 문자열 길이 (웹 클라이언트가 세는 방법과 같다) */
|
||||
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000;
|
||||
|
||||
/** 올리고 서명에 저장하지 않은 그림을 지우기까지의 시간 */
|
||||
export const UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 서명 저장(PUT)이 지우지 않고 남겨 두는, 막 올린 그림의 나이.
|
||||
* 올리기와 저장이 동시에 오면(그림을 넣자마자 [저장]) 저장 본문에는 아직 그 그림이 없다 — 그때 지우면
|
||||
* 편집기에 넣은 그림이 사라진다. 이 시간보다 오래된, 한 번도 저장하지 않은 그림은 저장할 때 지운다
|
||||
* (그 밖에는 하루 뒤 정리 작업이 지운다).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_UPLOAD_GRACE_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 위치 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR) 안의 하위 폴더.
|
||||
* 운영은 named volume, 개발은 바인드 마운트다 (.claude/skills/docker/SKILL.md).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_DIR = join(UPLOAD_DIR, 'mail-signatures');
|
||||
|
||||
/** 그림 응답의 브라우저 캐시 — 그림 id 는 내용이 바뀌지 않는다(새로 올리면 새 id). 본인만 보므로 private */
|
||||
export const SIGNATURE_IMAGE_CACHE_CONTROL = 'private, max-age=86400';
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
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 { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailSignatureDto,
|
||||
SignatureImageDto,
|
||||
UpdateMailSignatureDto,
|
||||
} from './dto/mail-signature.dto';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_CACHE_CONTROL,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from './signature-image-format';
|
||||
import { SignatureImageIdPipe } from './signature-image-id.pipe';
|
||||
|
||||
/** 서명 그림 응답 형식 — 저장할 때 파일 내용으로 확인한 형식 그대로 */
|
||||
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
|
||||
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
|
||||
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signature')
|
||||
export class MailSignatureController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 서명',
|
||||
description:
|
||||
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
|
||||
getSignature(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.getSignature(user.email);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '서명 저장 (전체 교체)',
|
||||
description:
|
||||
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
|
||||
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
|
||||
)
|
||||
saveSignature(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: UpdateMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.saveSignature(user.email, dto);
|
||||
}
|
||||
|
||||
@Post('images')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 올리기 (파일 1개)',
|
||||
description:
|
||||
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
|
||||
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
|
||||
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
|
||||
'서명을 저장하지 않으면 하루 뒤 지운다.',
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { file: { type: 'string', format: 'binary' } },
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(SignatureImageDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '올린 그림 — 이 id 를 서명 HTML 의 img 주소에 쓴다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'FILE_001',
|
||||
'허용되지 않는 형식 · 내용이 확장자·MIME 과 다른 형식 (400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`파일 없음 · 둘 수 있는 그림 ${SIGNATURE_IMAGES_STORED_MAX}장 초과 (400)`,
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '한 장 300KB 초과')
|
||||
uploadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
if (!file?.buffer) {
|
||||
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-file-missing' },
|
||||
});
|
||||
}
|
||||
return this.signatures.uploadImage(user.email, {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
data: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('images/:id')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 (표준 래퍼 없음, 내 그림만)',
|
||||
description:
|
||||
'편집기·미리 보기용 — 받는 사람은 이 주소를 쓰지 않는다(보낼 때 메일 안에 함께 싣는다). ' +
|
||||
'형식은 저장할 때 확인한 것, X-Content-Type-Options: nosniff, Content-Disposition: inline, 캐시는 private.',
|
||||
})
|
||||
@ApiProduces(...IMAGE_CONTENT_TYPES)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '그림 파일',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 그림 · 남의 그림 · 형식이 틀린 id')
|
||||
async downloadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', SignatureImageIdPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const image = await this.signatures.openImage(user.email, id);
|
||||
|
||||
// 본인만 보는 그림 — 공유 캐시에 남기지 않는다. 내용은 바뀌지 않으므로(새로 올리면 새 id) 브라우저는 하루 둔다
|
||||
res.setHeader('Cache-Control', SIGNATURE_IMAGE_CACHE_CONTROL);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return new StreamableFile(image.data, {
|
||||
type: image.type,
|
||||
disposition: `inline; filename="signature.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}"`,
|
||||
length: image.data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
|
||||
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
|
||||
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
|
||||
*/
|
||||
@Entity({ name: 'mail_signatures' })
|
||||
export class MailSignatureEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@Column({ name: 'body_html', type: 'text', nullable: true })
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@Column({ name: 'body_text', type: 'text', nullable: true })
|
||||
bodyText!: string | null;
|
||||
|
||||
/** 새 메일에 넣는다 */
|
||||
@Column({ name: 'is_for_new', type: 'boolean', default: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
/** 답장·전달에 넣는다 (인용 위) */
|
||||
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
|
||||
isForReply!: boolean;
|
||||
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import { SIGNATURE_IMAGE_MAX_BYTES } from './mail-signature.constants';
|
||||
import { MailSignatureController } from './mail-signature.controller';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
|
||||
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
|
||||
*
|
||||
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
|
||||
* 형식 사전 검사는 확장자 + MIME 짝(FILE_001), 크기 초과는 multer → 413(FILE_002).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MailSignatureEntity, MailSignatureImageEntity]),
|
||||
MulterModule.register({
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: SIGNATURE_IMAGE_MAX_BYTES, files: 1, fields: 10 },
|
||||
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const type = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extname(file.originalname).toLowerCase(),
|
||||
);
|
||||
if (!type || type !== file.mimetype) {
|
||||
callback(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-type-not-allowed' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [MailSignatureController],
|
||||
providers: [
|
||||
MailSignatureRepository,
|
||||
{
|
||||
provide: SignatureImageStorage,
|
||||
useFactory: () => new SignatureImageStorage(),
|
||||
},
|
||||
MailSignatureService,
|
||||
MailSignatureImageCleanup,
|
||||
],
|
||||
exports: [MailSignatureService],
|
||||
})
|
||||
export class MailSignatureModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
|
||||
/**
|
||||
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
|
||||
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
|
||||
*/
|
||||
const SIGNATURE_SAVE_LOCK = 0x53494731;
|
||||
|
||||
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
|
||||
export type PrepareSignature = (
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
) => {
|
||||
readonly signature: MailSignatureEntity;
|
||||
readonly referencedImageIds: readonly string[];
|
||||
};
|
||||
|
||||
/** 서명 저장 결과 */
|
||||
export interface SavedSignature {
|
||||
readonly signature: MailSignatureEntity;
|
||||
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
|
||||
readonly images: MailSignatureImageEntity[];
|
||||
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
|
||||
readonly removed: MailSignatureImageEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
|
||||
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureRepository {
|
||||
constructor(
|
||||
@InjectRepository(MailSignatureEntity)
|
||||
private readonly signatures: Repository<MailSignatureEntity>,
|
||||
@InjectRepository(MailSignatureImageEntity)
|
||||
private readonly images: Repository<MailSignatureImageEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
|
||||
return this.signatures.findOneBy({ userEmail });
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 — 올린 순서대로 */
|
||||
listImages(userEmail: string): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return this.images.findOneBy({ id, userEmail });
|
||||
}
|
||||
|
||||
countImages(userEmail: string): Promise<number> {
|
||||
return this.images.countBy({ userEmail });
|
||||
}
|
||||
|
||||
async insertImage(image: MailSignatureImageEntity): Promise<void> {
|
||||
await this.images.insert(image);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
|
||||
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
|
||||
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
|
||||
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
*/
|
||||
saveSignature(
|
||||
userEmail: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
|
||||
SIGNATURE_SAVE_LOCK,
|
||||
userEmail,
|
||||
]);
|
||||
const images = await manager.find(MailSignatureImageEntity, {
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const { signature, referencedImageIds } = prepare(images);
|
||||
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
|
||||
|
||||
const referenced = new Set(referencedImageIds);
|
||||
const attach = images
|
||||
.filter(
|
||||
(image) => referenced.has(image.id) && image.attachedAt === null,
|
||||
)
|
||||
.map((image) => image.id);
|
||||
const stale = images.filter(
|
||||
(image) =>
|
||||
!referenced.has(image.id) &&
|
||||
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
|
||||
if (attach.length > 0) {
|
||||
await manager.update(
|
||||
MailSignatureImageEntity,
|
||||
{ id: In(attach) },
|
||||
{ attachedAt: now },
|
||||
);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
await manager.delete(MailSignatureImageEntity, {
|
||||
id: In(stale.map((image) => image.id)),
|
||||
});
|
||||
}
|
||||
const attached = new Set(attach);
|
||||
return {
|
||||
signature,
|
||||
images: images
|
||||
.filter((image) => !stale.includes(image))
|
||||
.map((image) =>
|
||||
attached.has(image.id) ? { ...image, attachedAt: now } : image,
|
||||
),
|
||||
removed: stale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
|
||||
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { attachedAt: IsNull(), createdAt: LessThan(before) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다).
|
||||
* 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다).
|
||||
* @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다
|
||||
*/
|
||||
async deleteUnattachedImages(
|
||||
ids: readonly string[],
|
||||
): Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const result = await this.images
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids: [...ids] })
|
||||
.andWhere('attached_at IS NULL')
|
||||
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING "id", "file_name" (행의 키는 열 이름)
|
||||
.returning(['id', 'fileName'])
|
||||
.execute();
|
||||
const rows = (result.raw ?? []) as { id: string; file_name: string }[];
|
||||
return rows.map((row) => ({ id: row.id, fileName: row.file_name }));
|
||||
}
|
||||
|
||||
/** 표에 있는 모든 그림의 파일 이름 (정리 작업이 표에 없는 파일을 찾는다) */
|
||||
async listFileNames(): Promise<Set<string>> {
|
||||
const rows = await this.images.find({ select: { fileName: true } });
|
||||
return new Set(rows.map((row) => row.fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import type {
|
||||
MailSignatureRepository,
|
||||
PrepareSignature,
|
||||
SavedSignature,
|
||||
} from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
/**
|
||||
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
|
||||
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
|
||||
*/
|
||||
class FakeRepository {
|
||||
readonly signatures = new Map<string, MailSignatureEntity>();
|
||||
images: MailSignatureImageEntity[] = [];
|
||||
/** 다음 insertImage 를 실패시킨다 */
|
||||
failNextInsert = false;
|
||||
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
|
||||
deletedBeforeNextSave: string | null = null;
|
||||
readonly saveCalls: {
|
||||
referenced: readonly string[];
|
||||
keepUploadedAfter: Date;
|
||||
}[] = [];
|
||||
|
||||
findSignature(user: string): Promise<MailSignatureEntity | null> {
|
||||
return Promise.resolve(this.signatures.get(user) ?? null);
|
||||
}
|
||||
|
||||
listImages(user: string): Promise<MailSignatureImageEntity[]> {
|
||||
return Promise.resolve(this.images.filter((row) => row.userEmail === user));
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
user: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return Promise.resolve(
|
||||
this.images.find((row) => row.id === id && row.userEmail === user) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
countImages(user: string): Promise<number> {
|
||||
return Promise.resolve(
|
||||
this.images.filter((row) => row.userEmail === user).length,
|
||||
);
|
||||
}
|
||||
|
||||
insertImage(row: MailSignatureImageEntity): Promise<void> {
|
||||
if (this.failNextInsert) {
|
||||
this.failNextInsert = false;
|
||||
return Promise.reject(new Error('db down'));
|
||||
}
|
||||
this.images.push(row);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
saveSignature(
|
||||
user: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
if (this.deletedBeforeNextSave) {
|
||||
const gone = this.deletedBeforeNextSave;
|
||||
this.deletedBeforeNextSave = null;
|
||||
this.images = this.images.filter((row) => row.id !== gone);
|
||||
}
|
||||
const mine = this.images.filter((row) => row.userEmail === user);
|
||||
const { signature, referencedImageIds: referenced } = prepare(mine);
|
||||
this.saveCalls.push({ referenced, keepUploadedAfter });
|
||||
this.signatures.set(signature.userEmail, signature);
|
||||
for (const row of mine) {
|
||||
if (referenced.includes(row.id) && row.attachedAt === null) {
|
||||
row.attachedAt = now;
|
||||
}
|
||||
}
|
||||
const stale = mine.filter(
|
||||
(row) =>
|
||||
!referenced.includes(row.id) &&
|
||||
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
this.images = this.images.filter((row) => !stale.includes(row));
|
||||
return Promise.resolve({
|
||||
signature,
|
||||
images: mine.filter((row) => !stale.includes(row)),
|
||||
removed: stale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignatureService', () => {
|
||||
let dir: string;
|
||||
let repository: FakeRepository;
|
||||
let storage: SignatureImageStorage;
|
||||
let service: MailSignatureService;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const upload = (data: Buffer, name = 'logo.png', mimeType = 'image/png') =>
|
||||
service.uploadImage(ARA, { originalName: name, mimeType, data });
|
||||
|
||||
async function failureOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<{ code?: string; reason?: unknown }> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException
|
||||
? { code: error.code, reason: error.context?.reason }
|
||||
: { code: 'NOT_BUSINESS' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 이미 올려 둔 그림 한 장 (표 + 파일) */
|
||||
async function storedImage(
|
||||
overrides: Partial<MailSignatureImageEntity> = {},
|
||||
): Promise<MailSignatureImageEntity> {
|
||||
const id =
|
||||
overrides.id ??
|
||||
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: ARA,
|
||||
contentType: 'image/png',
|
||||
size: 33,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fileName: `${id}.png`,
|
||||
createdAt: new Date('2026-10-26T00:00:00Z'),
|
||||
attachedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
repository.images.push(row);
|
||||
await storage.write(row.fileName, pngBytes(row.width, row.height));
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
|
||||
repository = new FakeRepository();
|
||||
storage = new SignatureImageStorage(dir);
|
||||
service = new MailSignatureService(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('저장한 적 없으면 기본값을 돌려준다', async () => {
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장', () => {
|
||||
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
|
||||
const mine = await storedImage();
|
||||
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
|
||||
|
||||
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
|
||||
bodyHtml: html,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
|
||||
expect(saved).toEqual({
|
||||
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
|
||||
expect(repository.signatures.has(ARA)).toBe(true);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
|
||||
});
|
||||
|
||||
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
|
||||
const before = Date.now();
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
const { keepUploadedAfter } = repository.saveCalls[0];
|
||||
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
|
||||
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
|
||||
const kept = await storedImage();
|
||||
const gone = await storedImage();
|
||||
repository.deletedBeforeNextSave = gone.id;
|
||||
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(saved.bodyHtml).toBe(
|
||||
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
|
||||
|
||||
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
|
||||
repository.deletedBeforeNextSave = kept.id;
|
||||
const empty = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: '텍스트 서명',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(empty.bodyHtml).toBeNull();
|
||||
expect(empty.body).toBe('텍스트 서명');
|
||||
});
|
||||
|
||||
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
|
||||
const kept = await storedImage({ attachedAt: new Date() });
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
|
||||
expect(existsSync(join(dir, kept.fileName))).toBe(true);
|
||||
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
|
||||
});
|
||||
|
||||
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
jest
|
||||
.spyOn(storage, 'remove')
|
||||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
await expect(
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml: '<p>그림 없음</p>',
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
|
||||
expect(repository.images).not.toContainEqual(dropped);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일을 지우지 못했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
|
||||
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: '',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toBeNull();
|
||||
expect(saved.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it(`서명 하나에 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (같은 그림 두 번은 한 장 · 남의 그림은 정화가 지워 세지 않는다)`, async () => {
|
||||
const images: MailSignatureImageEntity[] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
||||
index += 1
|
||||
) {
|
||||
images.push(await storedImage());
|
||||
}
|
||||
const img = (id: string) => `<img src="${src(id)}" width="96">`;
|
||||
const save = (bodyHtml: string) =>
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
save(images.map((image) => img(image.id)).join('')),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
|
||||
const [a, b, c] = images;
|
||||
const saved = await save(
|
||||
[a, b, c, a].map((image) => img(image.id)).join('') +
|
||||
img('11111111-1111-4111-8111-111111111111'),
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
it('한도를 넘으면 400 VAL_001 — 저장하지 않는다 (텍스트 2,000자 · 정화한 서식 10,000자 · 정화 전 길이·비용)', async () => {
|
||||
const cases: [Record<string, string>, string][] = [
|
||||
[{ body: '가'.repeat(2_001) }, 'signature-text-too-long'],
|
||||
[
|
||||
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[
|
||||
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[{ bodyHtml: '<b>'.repeat(166_666) }, 'signature-html-too-complex'],
|
||||
];
|
||||
|
||||
for (const [input, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.saveSignature(ARA, {
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
...input,
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason });
|
||||
}
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
body: '가'.repeat(2_000),
|
||||
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toHaveLength(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 올리기', () => {
|
||||
it('PNG 를 내용으로 확인하고 서버가 만든 이름으로 저장한다 — 가로·세로는 파일 머리에서 읽는다', async () => {
|
||||
const data = pngBytes(480, 120, 2_048);
|
||||
|
||||
const image = await upload(data, '로고 (최종).PNG');
|
||||
|
||||
expect(image).toEqual({
|
||||
id: expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
) as string,
|
||||
type: 'image/png',
|
||||
size: 2_048,
|
||||
width: 480,
|
||||
height: 120,
|
||||
});
|
||||
const [row] = repository.images;
|
||||
expect(row).toMatchObject({
|
||||
userEmail: ARA,
|
||||
fileName: `${image.id}.png`,
|
||||
attachedAt: null,
|
||||
});
|
||||
expect(existsSync(join(dir, `${image.id}.png`))).toBe(true);
|
||||
});
|
||||
|
||||
it('내용이 확장자·MIME 과 다른 형식이면 FILE_001 — 아무것도 저장하지 않는다', async () => {
|
||||
for (const [data, name, mimeType] of [
|
||||
// PNG 내용인데 이름은 JPEG
|
||||
[pngBytes(10, 10), 'logo.jpg', 'image/jpeg'],
|
||||
// GIF 내용인데 PNG 라고 한다
|
||||
[gifBytes(10, 10), 'logo.png', 'image/png'],
|
||||
// 그림이 아니다
|
||||
[Buffer.from('<svg onload="x()"></svg>'), 'logo.png', 'image/png'],
|
||||
// 이름과 MIME 이 서로 다르다
|
||||
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
||||
] as const) {
|
||||
expect(await failureOf(() => upload(data, name, mimeType))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-content-mismatch',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(`가로·세로가 각각 ${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀을 넘으면 FILE_001 — 아무것도 저장하지 않는다 (작은 파일 안의 거대한 그림 · 32비트 PNG 값)`, async () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 10],
|
||||
[10, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
// 표의 integer(2^31-1)를 넘는 PNG 가로 — 예전에는 INSERT 가 500 이 됐다
|
||||
[2 ** 31, 10],
|
||||
[2 ** 32 - 1, 2 ** 32 - 1],
|
||||
]) {
|
||||
expect(await failureOf(() => upload(pngBytes(width, height)))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-dimensions',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(
|
||||
upload(
|
||||
pngBytes(
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('300KB 를 넘으면 FILE_002 (multer 가 먼저 막지만 한 번 더)', async () => {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1)),
|
||||
),
|
||||
).toEqual({ code: 'FILE_002', reason: 'signature-image-too-large' });
|
||||
await expect(
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES)),
|
||||
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
|
||||
});
|
||||
|
||||
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
|
||||
upload(pngBytes(10, 10)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
);
|
||||
const rejected = results.flatMap((result): unknown[] =>
|
||||
result.status === 'rejected' ? [result.reason] : [],
|
||||
);
|
||||
expect(rejected).toHaveLength(2);
|
||||
for (const reason of rejected) {
|
||||
expect(reason).toBeInstanceOf(BusinessException);
|
||||
expect((reason as BusinessException).code).toBe('VAL_001');
|
||||
expect((reason as BusinessException).context?.reason).toBe(
|
||||
'signature-image-storage-full',
|
||||
);
|
||||
}
|
||||
expect(repository.images).toHaveLength(SIGNATURE_IMAGES_STORED_MAX);
|
||||
|
||||
// 다른 사람은 따로 센다
|
||||
await expect(
|
||||
service.uploadImage(DOYUN, {
|
||||
originalName: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
}),
|
||||
).resolves.toMatchObject({ type: 'image/png' });
|
||||
});
|
||||
|
||||
it('표에 넣지 못하면 쓴 파일을 지우고 실패를 그대로 던진다 — 다음 올리기는 된다', async () => {
|
||||
repository.failNextInsert = true;
|
||||
|
||||
expect(await failureOf(() => upload(pngBytes(10, 10)))).toEqual({
|
||||
code: 'NOT_BUSINESS',
|
||||
});
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(upload(pngBytes(10, 10))).resolves.toMatchObject({
|
||||
type: 'image/png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 읽기', () => {
|
||||
it('주인에게는 파일과 형식을 준다 — 남의 그림·없는 그림은 RES_001', async () => {
|
||||
const mine = await storedImage({ width: 7, height: 3 });
|
||||
|
||||
await expect(service.openImage(ARA, mine.id)).resolves.toEqual({
|
||||
data: pngBytes(7, 3),
|
||||
type: 'image/png',
|
||||
});
|
||||
for (const [user, id] of [
|
||||
[DOYUN, mine.id],
|
||||
[ARA, '11111111-1111-4111-8111-111111111111'],
|
||||
]) {
|
||||
expect(await failureOf(() => service.openImage(user, id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('표에는 있는데 파일이 없으면 RES_001 — 경고를 남긴다', async () => {
|
||||
const mine = await storedImage();
|
||||
await storage.remove(mine.fileName);
|
||||
|
||||
expect(await failureOf(() => service.openImage(ARA, mine.id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일이 없습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('발송용 — 내 그림 id 전부, 그리고 요청한 순서대로 내 그림만 읽는다 (파일이 없는 그림은 빼고 경고)', async () => {
|
||||
const first = await storedImage();
|
||||
const second = await storedImage({ attachedAt: new Date() });
|
||||
const missing = await storedImage();
|
||||
await storage.remove(missing.fileName);
|
||||
const others = await storedImage({ userEmail: DOYUN });
|
||||
|
||||
await expect(service.imageIdsOf(ARA)).resolves.toEqual(
|
||||
new Set([first.id, second.id, missing.id]),
|
||||
);
|
||||
|
||||
const loaded = await service.loadImages(ARA, [
|
||||
second.id,
|
||||
others.id,
|
||||
missing.id,
|
||||
first.id,
|
||||
]);
|
||||
expect(loaded.map((image) => image.id)).toEqual([second.id, first.id]);
|
||||
expect(loaded[0]).toEqual({
|
||||
id: second.id,
|
||||
type: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
await expect(service.loadImages(ARA, [])).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
signatureImagePath,
|
||||
} from '@/modules/mail/html/signature-images';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import type {
|
||||
MailSignature,
|
||||
MailSignatureInput,
|
||||
SignatureImage,
|
||||
SignatureImageUpload,
|
||||
StoredSignatureImage,
|
||||
} from './mail-signature.types';
|
||||
import {
|
||||
SIGNATURE_IMAGE_EXTENSIONS,
|
||||
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
|
||||
type SignatureImageType,
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toImage(row: MailSignatureImageEntity): SignatureImage {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.contentType as SignatureImageType,
|
||||
size: row.size,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 올린 이름의 확장자 (소문자, 점 포함) */
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
|
||||
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
|
||||
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
|
||||
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureService {
|
||||
private readonly logger = new Logger(MailSignatureService.name);
|
||||
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
|
||||
private readonly uploadQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
async getSignature(userEmail: string): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const [row, images] = await Promise.all([
|
||||
this.repository.findSignature(user),
|
||||
this.repository.listImages(user),
|
||||
]);
|
||||
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
|
||||
return this.toSignature(row, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
|
||||
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
|
||||
*/
|
||||
async saveSignature(
|
||||
userEmail: string,
|
||||
input: MailSignatureInput,
|
||||
): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const body = input.body ? input.body : null;
|
||||
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-text-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
|
||||
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
|
||||
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
|
||||
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-too-many-images',
|
||||
count: referenced.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.repository.saveSignature(
|
||||
user,
|
||||
(images) => {
|
||||
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
|
||||
const existing = new Set(images.map((image) => image.id));
|
||||
const html =
|
||||
bodyHtml === null
|
||||
? null
|
||||
: rewriteSignatureImages(bodyHtml, (id) =>
|
||||
existing.has(id) ? signatureImagePath(id) : null,
|
||||
);
|
||||
const kept = html !== null && html.trim() !== '' ? html : null;
|
||||
return {
|
||||
signature: {
|
||||
userEmail: user,
|
||||
bodyHtml: kept,
|
||||
bodyText: body,
|
||||
isForNew: input.isForNew,
|
||||
isForReply: input.isForReply,
|
||||
updatedAt: now,
|
||||
},
|
||||
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
|
||||
};
|
||||
},
|
||||
now,
|
||||
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
|
||||
);
|
||||
await this.removeFiles(saved.removed, user);
|
||||
|
||||
return this.toSignature(saved.signature, saved.images);
|
||||
}
|
||||
|
||||
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
|
||||
uploadImage(
|
||||
userEmail: string,
|
||||
upload: SignatureImageUpload,
|
||||
): Promise<SignatureImage> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const info = detectSignatureImage(upload.data);
|
||||
const declared = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extensionOf(upload.originalName),
|
||||
);
|
||||
if (!info || info.type !== declared || info.type !== upload.mimeType) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-content-mismatch' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 가로·세로 각각 4096 픽셀까지 — 작은 파일 안의 거대한 그림(압축 폭탄)·표의 integer 를 넘는 값을 받지 않는다
|
||||
if (!isWithinSignatureImageDimensions(info)) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: {
|
||||
reason: 'signature-image-dimensions',
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (upload.data.length > SIGNATURE_IMAGE_MAX_BYTES) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_002', {
|
||||
context: {
|
||||
reason: 'signature-image-too-large',
|
||||
size: upload.data.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.inUploadQueue(user, async () => {
|
||||
const count = await this.repository.countImages(user);
|
||||
if (count >= SIGNATURE_IMAGES_STORED_MAX) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-storage-full', count },
|
||||
});
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: user,
|
||||
contentType: info.type,
|
||||
size: upload.data.length,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
fileName: `${id}.${SIGNATURE_IMAGE_EXTENSIONS[info.type]}`,
|
||||
createdAt: new Date(),
|
||||
attachedAt: null,
|
||||
};
|
||||
await this.storage.write(row.fileName, upload.data);
|
||||
try {
|
||||
await this.repository.insertImage(row);
|
||||
} catch (error) {
|
||||
// 표에 넣지 못한 파일은 남기지 않는다
|
||||
await this.storage.remove(row.fileName).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return toImage(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 파일 — 남의 그림·없는 그림은 RES_001 */
|
||||
async openImage(
|
||||
userEmail: string,
|
||||
id: string,
|
||||
): Promise<{ data: Buffer; type: SignatureImageType }> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const row = await this.repository.findImage(id, user);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
if (row) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림 파일이 없습니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'signature-image-not-found' },
|
||||
});
|
||||
}
|
||||
return { data, type: row.contentType as SignatureImageType };
|
||||
}
|
||||
|
||||
/** 보내는 정화가 남겨도 되는 그림 id — 그 사람의 그림 전부 (서명에 붙었든 아니든) */
|
||||
async imageIdsOf(userEmail: string): Promise<ReadonlySet<string>> {
|
||||
const images = await this.repository.listImages(normalizeUser(userEmail));
|
||||
return new Set(images.map((image) => image.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일에 함께 실을 그림을 읽는다 — 그 사람의 그림만, 요청한 순서대로. 파일이 없는 그림은 빼고 경고만 남긴다
|
||||
* (부르는 쪽이 그 img 를 지운다).
|
||||
*/
|
||||
async loadImages(
|
||||
userEmail: string,
|
||||
ids: readonly string[],
|
||||
): Promise<StoredSignatureImage[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const user = normalizeUser(userEmail);
|
||||
const rows = new Map(
|
||||
(await this.repository.listImages(user)).map((row) => [row.id, row]),
|
||||
);
|
||||
|
||||
const loaded: StoredSignatureImage[] = [];
|
||||
for (const id of ids) {
|
||||
const row = rows.get(id);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림을 메일에 싣지 못해 뺍니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
loaded.push({ id, type: row.contentType as SignatureImageType, data });
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
|
||||
private async sanitizeSignatureHtml(
|
||||
user: string,
|
||||
html: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!html) return null;
|
||||
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
|
||||
if (html.length > MAX_BODY_HTML_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-html-too-long', length: html.length },
|
||||
});
|
||||
}
|
||||
const budget = checkHtmlParseBudget(html);
|
||||
if (!budget.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-complex',
|
||||
detail: budget.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = sanitizeOutgoingHtml(html, {
|
||||
signatureImageIds: await this.imageIdsOf(user),
|
||||
});
|
||||
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-long',
|
||||
length: sanitized.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sanitized === '' ? null : sanitized;
|
||||
}
|
||||
|
||||
private toSignature(
|
||||
row: MailSignatureEntity,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): MailSignature {
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
|
||||
return {
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
isForNew: row.isForNew,
|
||||
isForReply: row.isForReply,
|
||||
images: used.flatMap((id) => {
|
||||
const image = byId.get(id);
|
||||
return image ? [toImage(image)] : [];
|
||||
}),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
|
||||
private async removeFiles(
|
||||
removed: readonly MailSignatureImageEntity[],
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
for (const image of removed) {
|
||||
try {
|
||||
await this.storage.remove(image.fileName);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 서명 그림 파일을 지우지 못했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 사용자의 작업을 차례로 돌린다 (앞 작업이 실패해도 다음 작업은 돈다) */
|
||||
private inUploadQueue<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.uploadQueues.get(user) ?? Promise.resolve();
|
||||
const run = previous.then(task, task);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.uploadQueues.set(user, tail);
|
||||
void tail.then(() => {
|
||||
if (this.uploadQueues.get(user) === tail) this.uploadQueues.delete(user);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SignatureImageType } from './signature-image-format';
|
||||
|
||||
/**
|
||||
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
|
||||
*/
|
||||
|
||||
export interface SignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
/** 바이트 */
|
||||
readonly size: number;
|
||||
/** 원본 픽셀 */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface MailSignature {
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
|
||||
readonly bodyHtml: string | null;
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
|
||||
readonly body: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
|
||||
readonly images: SignatureImage[];
|
||||
/** 저장한 적 없으면 null */
|
||||
readonly updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** PUT /api/mail-signature 본문 */
|
||||
export interface MailSignatureInput {
|
||||
readonly bodyHtml?: string | null;
|
||||
readonly body?: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
}
|
||||
|
||||
/** 메일에 함께 실을 그림 — 발송 때 읽는다 */
|
||||
export interface StoredSignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
/** 올린 그림 파일 (multer 가 메모리에 받은 것 — 한 장 300KB 이하) */
|
||||
export interface SignatureImageUpload {
|
||||
readonly originalName: string;
|
||||
readonly mimeType: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
gifBytes as gif,
|
||||
jpegBytes as jpeg,
|
||||
pngBytes as png,
|
||||
} from '@/../test/image-fixtures';
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
import {
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
|
||||
describe('isWithinSignatureImageDimensions (서명 그림 크기 상한)', () => {
|
||||
it(`가로·세로가 각각 1~${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀이면 받는다`, () => {
|
||||
expect(isWithinSignatureImageDimensions({ width: 1, height: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isWithinSignatureImageDimensions({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('하나라도 넘거나 0 이면 받지 않는다 — PNG 머리의 32비트 값(표의 integer 를 넘는다) 포함', () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 1],
|
||||
[1, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
[0, 10],
|
||||
[2 ** 31, 10],
|
||||
]) {
|
||||
expect(isWithinSignatureImageDimensions({ width, height })).toBe(false);
|
||||
}
|
||||
// PNG 는 머리에 32비트로 적는다 — 읽기는 하고(형식은 맞다) 크기 검사가 거른다
|
||||
const huge = detectSignatureImage(png(2 ** 32 - 1, 2 ** 32 - 1));
|
||||
expect(huge).toEqual({
|
||||
type: 'image/png',
|
||||
width: 2 ** 32 - 1,
|
||||
height: 2 ** 32 - 1,
|
||||
});
|
||||
expect(huge && isWithinSignatureImageDimensions(huge)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSignatureImage (서명 그림 형식·크기)', () => {
|
||||
it('PNG·GIF(87a·89a)·JPEG(기본·점진) 의 형식과 가로·세로를 읽는다', () => {
|
||||
expect(detectSignatureImage(png(640, 120))).toEqual({
|
||||
type: 'image/png',
|
||||
width: 640,
|
||||
height: 120,
|
||||
});
|
||||
expect(detectSignatureImage(gif(300, 80))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 300,
|
||||
height: 80,
|
||||
});
|
||||
expect(detectSignatureImage(gif(1, 2, 'GIF87a'))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 1,
|
||||
height: 2,
|
||||
});
|
||||
expect(detectSignatureImage(jpeg(1024, 256))).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 1024,
|
||||
height: 256,
|
||||
});
|
||||
// 점진(SOF2) · 표지 앞 채움 바이트
|
||||
expect(
|
||||
detectSignatureImage(jpeg(96, 48, { sof: 0xc2, fill: true })),
|
||||
).toEqual({ type: 'image/jpeg', width: 96, height: 48 });
|
||||
});
|
||||
|
||||
it('내용이 그 형식이 아니면 받지 않는다 — WebP·SVG·HTML·빈 파일', () => {
|
||||
for (const data of [
|
||||
Buffer.from('RIFF\x00\x00\x00\x00WEBPVP8 ', 'binary'),
|
||||
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
|
||||
Buffer.from('<html><script>alert(1)</script></html>'),
|
||||
Buffer.alloc(0),
|
||||
]) {
|
||||
expect(detectSignatureImage(data)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('머리가 잘렸거나 망가졌거나 크기가 0 이면 받지 않는다', () => {
|
||||
expect(detectSignatureImage(png(10, 10).subarray(0, 20))).toBeNull();
|
||||
expect(detectSignatureImage(png(0, 10))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 0))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 10).subarray(0, 8))).toBeNull();
|
||||
expect(detectSignatureImage(jpeg(0, 10))).toBeNull();
|
||||
|
||||
// IHDR 가 첫 덩어리가 아니다
|
||||
const broken = png(10, 10);
|
||||
broken.write('IDAT', 12, 'ascii');
|
||||
expect(detectSignatureImage(broken)).toBeNull();
|
||||
|
||||
// JPEG: SOF 전에 그림 자료(SOS)가 오거나, 조각 길이가 파일 밖을 가리킨다
|
||||
expect(
|
||||
detectSignatureImage(
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x7f, 0xff])),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xd9])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('긴 JPEG 도 조각 길이를 따라 앞으로만 간다 (입력 길이에 비례)', () => {
|
||||
// 64KB 짜리 주석(COM) 조각 4개 뒤에 SOF
|
||||
const comment = Buffer.alloc(0xffff + 2);
|
||||
comment[0] = 0xff;
|
||||
comment[1] = 0xfe;
|
||||
comment.writeUInt16BE(0xffff, 2);
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
jpeg(20, 10).subarray(2),
|
||||
]);
|
||||
|
||||
expect(detectSignatureImage(data)).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 20,
|
||||
height: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 서명 그림 형식 확인 (순수 함수) — 파일 앞부분(매직 바이트)으로 형식을 알아내고 머리에서 가로·세로 픽셀을 읽는다.
|
||||
* 확장자·MIME 은 클라이언트가 정하므로 믿지 않는다 — 이름이 .png 여도 내용이 PNG 가 아니면 받지 않는다 (.claude/rules/security.md).
|
||||
* 그림을 풀지(디코드) 않는다 — 머리만 읽으므로 네이티브 라이브러리·새 의존성이 필요 없다.
|
||||
*/
|
||||
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif';
|
||||
|
||||
export interface SignatureImageInfo {
|
||||
readonly type: SignatureImageType;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받을 수 있는 크기인가 — 가로·세로가 각각 1~SIGNATURE_IMAGE_MAX_DIMENSION(4096) 픽셀.
|
||||
* (0 은 detectSignatureImage 가 이미 거른다)
|
||||
*/
|
||||
export function isWithinSignatureImageDimensions(
|
||||
info: Pick<SignatureImageInfo, 'width' | 'height'>,
|
||||
): boolean {
|
||||
return (
|
||||
info.width >= 1 &&
|
||||
info.height >= 1 &&
|
||||
info.width <= SIGNATURE_IMAGE_MAX_DIMENSION &&
|
||||
info.height <= SIGNATURE_IMAGE_MAX_DIMENSION
|
||||
);
|
||||
}
|
||||
|
||||
/** 형식별 저장 확장자 — 서버가 만드는 파일 이름에 쓴다 */
|
||||
export const SIGNATURE_IMAGE_EXTENSIONS: Readonly<
|
||||
Record<SignatureImageType, string>
|
||||
> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
/** 받는 확장자 → 형식 (업로드 사전 검사 — 확장자와 MIME 이 이 짝이어야 한다) */
|
||||
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: ReadonlyMap<
|
||||
string,
|
||||
SignatureImageType
|
||||
> = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
/** PNG 첫 덩어리 IHDR — 길이(4) 'IHDR'(4) 가로(4) 세로(4) … */
|
||||
const PNG_IHDR_LENGTH = 13;
|
||||
const PNG_IHDR_TYPE = Buffer.from('IHDR', 'ascii');
|
||||
|
||||
const GIF_SIGNATURES = [
|
||||
Buffer.from('GIF87a', 'ascii'),
|
||||
Buffer.from('GIF89a', 'ascii'),
|
||||
];
|
||||
/** GIF 논리 화면 가로·세로 (little-endian 16비트) 위치 */
|
||||
const GIF_WIDTH_OFFSET = 6;
|
||||
const GIF_HEIGHT_OFFSET = 8;
|
||||
|
||||
const JPEG_SOI = [0xff, 0xd8, 0xff];
|
||||
const JPEG_MARKER_PREFIX = 0xff;
|
||||
/** 길이 없이 홀로 오는 표지 — TEM, RST0~7 */
|
||||
const JPEG_STANDALONE = new Set([
|
||||
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
|
||||
]);
|
||||
/** 그림 자료가 시작되거나 끝나는 표지 — 그 전에 SOF 가 있어야 한다 */
|
||||
const JPEG_SOS = 0xda;
|
||||
const JPEG_EOI = 0xd9;
|
||||
/** 프레임 머리(SOF0~15) 중 프레임이 아닌 것 — DHT(C4)·JPG(C8)·DAC(CC) */
|
||||
const JPEG_NOT_SOF = new Set([0xc4, 0xc8, 0xcc]);
|
||||
const JPEG_SOF_FIRST = 0xc0;
|
||||
const JPEG_SOF_LAST = 0xcf;
|
||||
|
||||
function startsWith(data: Buffer, prefix: Buffer | readonly number[]): boolean {
|
||||
if (data.length < prefix.length) return false;
|
||||
for (let index = 0; index < prefix.length; index++) {
|
||||
if (data[index] !== prefix[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sized(
|
||||
type: SignatureImageType,
|
||||
width: number,
|
||||
height: number,
|
||||
): SignatureImageInfo | null {
|
||||
return width > 0 && height > 0 ? { type, width, height } : null;
|
||||
}
|
||||
|
||||
function readPng(data: Buffer): SignatureImageInfo | null {
|
||||
// 서명 8 + 길이 4 + 형식 4 + 가로 4 + 세로 4
|
||||
if (data.length < 24) return null;
|
||||
if (data.readUInt32BE(8) !== PNG_IHDR_LENGTH) return null;
|
||||
if (!data.subarray(12, 16).equals(PNG_IHDR_TYPE)) return null;
|
||||
return sized('image/png', data.readUInt32BE(16), data.readUInt32BE(20));
|
||||
}
|
||||
|
||||
function readGif(data: Buffer): SignatureImageInfo | null {
|
||||
if (data.length < GIF_HEIGHT_OFFSET + 2) return null;
|
||||
return sized(
|
||||
'image/gif',
|
||||
data.readUInt16LE(GIF_WIDTH_OFFSET),
|
||||
data.readUInt16LE(GIF_HEIGHT_OFFSET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JPEG 은 표지(0xFF xx)와 길이로 이어진 조각을 따라가 첫 프레임 머리(SOFn)에서 크기를 읽는다.
|
||||
* 조각 길이를 따라 앞으로만 가므로 입력 길이에 비례해 끝난다. SOF 전에 그림 자료(SOS)·끝(EOI)이 오면 받지 않는다.
|
||||
*/
|
||||
function readJpeg(data: Buffer): SignatureImageInfo | null {
|
||||
let index = 2;
|
||||
while (index < data.length) {
|
||||
if (data[index] !== JPEG_MARKER_PREFIX) return null;
|
||||
// 표지 앞의 채움 바이트(0xFF 여러 개)는 건너뛴다
|
||||
while (index < data.length && data[index] === JPEG_MARKER_PREFIX) index++;
|
||||
if (index >= data.length) return null;
|
||||
const marker = data[index];
|
||||
index++;
|
||||
|
||||
if (JPEG_STANDALONE.has(marker)) continue;
|
||||
if (marker === JPEG_SOS || marker === JPEG_EOI) return null;
|
||||
if (index + 2 > data.length) return null;
|
||||
|
||||
const length = data.readUInt16BE(index);
|
||||
if (length < 2 || index + length > data.length) return null;
|
||||
|
||||
const isFrame =
|
||||
marker >= JPEG_SOF_FIRST &&
|
||||
marker <= JPEG_SOF_LAST &&
|
||||
!JPEG_NOT_SOF.has(marker);
|
||||
if (isFrame) {
|
||||
// 길이(2) 정밀도(1) 세로(2) 가로(2)
|
||||
if (length < 7) return null;
|
||||
return sized(
|
||||
'image/jpeg',
|
||||
data.readUInt16BE(index + 5),
|
||||
data.readUInt16BE(index + 3),
|
||||
);
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 내용으로 형식과 크기를 알아낸다 — PNG·JPEG·GIF 가 아니거나 머리가 망가졌으면 null */
|
||||
export function detectSignatureImage(data: Buffer): SignatureImageInfo | null {
|
||||
if (startsWith(data, PNG_SIGNATURE)) return readPng(data);
|
||||
if (GIF_SIGNATURES.some((signature) => startsWith(data, signature))) {
|
||||
return readGif(data);
|
||||
}
|
||||
if (startsWith(data, JPEG_SOI)) return readJpeg(data);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID */
|
||||
const SIGNATURE_IMAGE_ID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
|
||||
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-signature-image-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, rmSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NAME = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.png';
|
||||
|
||||
describe('SignatureImageStorage (서명 그림 파일)', () => {
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'signature-storage-'));
|
||||
// 아직 없는 하위 폴더 — 처음 쓸 때 만든다
|
||||
dir = join(root, 'mail-signatures');
|
||||
storage = new SignatureImageStorage(dir);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('쓰고 읽고 지운다 — 없는 파일은 null, 두 번 지워도 된다', async () => {
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
await expect(storage.list()).resolves.toEqual([]);
|
||||
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('png'));
|
||||
expect((await storage.list()).map((file) => file.fileName)).toEqual([NAME]);
|
||||
|
||||
await storage.remove(NAME);
|
||||
await storage.remove(NAME);
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('같은 이름을 덮어쓰지 않는다', async () => {
|
||||
await storage.write(NAME, Buffer.from('first'));
|
||||
|
||||
await expect(storage.write(NAME, Buffer.from('second'))).rejects.toThrow();
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('first'));
|
||||
});
|
||||
|
||||
it('서버가 만든 이름(UUID.png·jpg·gif)이 아니면 경로로 쓰지 않는다', async () => {
|
||||
for (const name of [
|
||||
'../../etc/passwd',
|
||||
`../${NAME}`,
|
||||
`${NAME}/x`,
|
||||
NAME.toUpperCase(),
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.svg',
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.jpeg',
|
||||
'',
|
||||
]) {
|
||||
await expect(storage.write(name, Buffer.from('x'))).rejects.toThrow(
|
||||
'서명 그림 파일 이름 형식이 올바르지 않습니다.',
|
||||
);
|
||||
await expect(storage.read(name)).rejects.toThrow();
|
||||
await expect(storage.remove(name)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('그룹은 쓸 수 없고 다른 사람은 읽을 수도 없다 (POSIX — Windows 는 권한 비트가 없다)', async () => {
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// 만들 때 0o640 — umask 가 더 좁히는 것은 괜찮다
|
||||
const mode = statSync(join(dir, NAME)).mode;
|
||||
expect(mode & 0o027).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SIGNATURE_IMAGE_DIR } from './mail-signature.constants';
|
||||
|
||||
/** 서버가 만든 파일 이름만 다룬다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const STORED_FILE_NAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|gif)$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
export interface StoredFile {
|
||||
readonly fileName: string;
|
||||
readonly modifiedAt: Date;
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 저장소 — 업로드 볼륨의 하위 폴더(SIGNATURE_IMAGE_DIR).
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식을 정해 준다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다 (생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
|
||||
*/
|
||||
export class SignatureImageStorage {
|
||||
constructor(private readonly dir: string = SIGNATURE_IMAGE_DIR) {}
|
||||
|
||||
async write(fileName: string, data: Buffer): Promise<void> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
|
||||
await writeFile(this.pathOf(fileName), data, {
|
||||
flag: 'wx',
|
||||
mode: FILE_MODE,
|
||||
});
|
||||
}
|
||||
|
||||
/** 없으면 null */
|
||||
async read(fileName: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await readFile(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 지운다 — 이미 없으면 그대로 둔다 */
|
||||
async remove(fileName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */
|
||||
async list(): Promise<StoredFile[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const files: StoredFile[] = [];
|
||||
for (const fileName of names.filter((name) =>
|
||||
STORED_FILE_NAME.test(name),
|
||||
)) {
|
||||
try {
|
||||
const { mtime } = await stat(join(this.dir, fileName));
|
||||
files.push({ fileName, modifiedAt: mtime });
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private pathOf(fileName: string): string {
|
||||
if (!STORED_FILE_NAME.test(fileName)) {
|
||||
// 서버가 만든 이름이 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('서명 그림 파일 이름 형식이 올바르지 않습니다.');
|
||||
}
|
||||
return join(this.dir, fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
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 { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
|
||||
import { DraftDto, DraftSavedDto } from './dto/mail-response.dto';
|
||||
import { DraftBodyDto } from './dto/send-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { DRAFTS_ROUTE } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/** 저장 요청의 400 실패 — 발송 본문과 같은 한도 */
|
||||
const SAVE_BAD_REQUEST =
|
||||
`MAIL_002 받는 사람 합계 100명 초과 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음 · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · ` +
|
||||
'VAL_001 형식(제목 255자 초과·draftId 같은 모르는 필드 포함) · FILE_001 첨부 형식 (모두 400)';
|
||||
|
||||
// 임시 저장 (계약 2절 "임시 저장") — 목록·버리기·되돌리기는 메일 API(GET·DELETE·PATCH /api/messages) 그대로다.
|
||||
// 저장 본문은 발송 본문과 같은 필드·한도이고 JSON 본문 4MB 까지 받는다 (app.setup.ts — 앞단 nginx 도 같은 값이어야 한다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller(DRAFTS_ROUTE)
|
||||
export class DraftController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '이어 쓸 임시 저장본',
|
||||
description:
|
||||
'서식 본문은 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값이다 — 다른 메일 프로그램이 만든 초안도 같다. ' +
|
||||
'임시보관함의 초안이 아니면(발송 중에 생긴 내부 초안 포함) 404.',
|
||||
})
|
||||
@ApiStandardResponse(DraftDto, { description: '임시 저장본' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일 · 임시보관함의 초안이 아님')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
getDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.getDraft(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 처음 저장',
|
||||
description:
|
||||
'발송 본문과 같은 필드·한도(받는 사람 0명도 된다, draftId 없음). 발송 중복 방지용 Message-ID·지문 헤더는 넣지 않는다. ' +
|
||||
'본문이 너무 길면 400 MAIL_007.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '저장한 임시 저장본 — 다음 저장은 PUT /api/drafts/{id}',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도 (텍스트 100,000자·서식 500,000자·정화 전 비용·정화 뒤 2,000,000바이트)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
createDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.createDraft(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 바꿔 저장 (전체 교체, 새 id)',
|
||||
description:
|
||||
'메일은 고칠 수 없어 새 초안을 만든 뒤 옛것을 지운다 — 응답의 id·attachments 로 바꿔 쓴다. ' +
|
||||
'{id} 가 임시보관함의 초안이 아니면 아무것도 지우지 않고 404 RES_001, ' +
|
||||
'옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 저장하지 않고 409 MAIL_008 — 클라이언트는 POST 로 새로 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
description: '새 임시 저장본 (새 id)',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '임시보관함의 초안이 아님 (지우지 않았다)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_008',
|
||||
'다른 곳에서 먼저 바뀌었거나 지워짐 — 저장하지 않았다 (POST 로 새로 남긴다)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
replaceDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.replaceDraft(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import type {
|
||||
Draft,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailQuota,
|
||||
MessageAttachment,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
RecipientSuggestion,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +67,20 @@ export class MailAttachmentDto implements MailAttachment {
|
||||
size!: number;
|
||||
}
|
||||
|
||||
/** 메일 상세의 첨부 — 업로드 응답·임시 저장본의 첨부(MailAttachmentDto)와 달리 isInline 이 있다 */
|
||||
export class MessageAttachmentDto
|
||||
extends MailAttachmentDto
|
||||
implements MessageAttachment
|
||||
{
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 부분을 돌려준 htmlBody 안에 그림으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다 (전달할 때는 그대로 모두 붙인다). ' +
|
||||
'너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우·텍스트 메일은 false',
|
||||
example: false,
|
||||
})
|
||||
isInline!: boolean;
|
||||
}
|
||||
|
||||
export class MailboxDto implements Mailbox {
|
||||
@ApiProperty({ description: '메일함 id', example: 'a' })
|
||||
id!: string;
|
||||
@@ -187,7 +205,12 @@ export class MessageSummaryDto implements MessageSummary {
|
||||
@ApiProperty({ description: '중요 표시 여부', example: false })
|
||||
isFlagged!: boolean;
|
||||
|
||||
@ApiProperty({ description: '첨부 여부', example: true })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'첨부 여부 — 본문 그림(본문에 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB)이 아닌 첨부가 하나라도 있으면 true. ' +
|
||||
'서명 로고처럼 본문 그림만 있으면 false',
|
||||
example: true,
|
||||
})
|
||||
hasAttachment!: boolean;
|
||||
|
||||
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
|
||||
@@ -268,10 +291,11 @@ export class MessageDetailDto
|
||||
isBodyTruncated!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '첨부 (본문 내장 이미지 포함)',
|
||||
type: [MailAttachmentDto],
|
||||
description:
|
||||
'첨부 (본문 내장 이미지 포함) — 본문에 그림으로 넣은 부분은 isInline: true',
|
||||
type: [MessageAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
attachments!: MessageAttachmentDto[];
|
||||
}
|
||||
|
||||
export class MessagePageDto {
|
||||
@@ -289,3 +313,126 @@ export class SentMessageDto {
|
||||
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
|
||||
export class DraftDto implements Draft {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'임시 저장본 id — 저장할 때마다 바뀐다 (응답의 id 로 바꿔 쓴다)',
|
||||
example: 'faaaaabi',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '받는 사람 (0명도 된다)',
|
||||
type: [MailAddressDto],
|
||||
})
|
||||
to!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({ description: '참조', type: [MailAddressDto] })
|
||||
cc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({ description: '숨은 참조', type: [MailAddressDto] })
|
||||
bcc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '제목 (없으면 빈 문자열)',
|
||||
example: 'Re: 메일 서버 이전 일정 공유',
|
||||
})
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 대체 본문 (없으면 빈 문자열)',
|
||||
example: '도윤님, 확인했습니다.',
|
||||
})
|
||||
body!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'서식 본문 — 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값. 없거나 비면 null. ' +
|
||||
'다른 메일 프로그램이 만든 초안도 같은 정화를 거친다 (정화 한도를 넘는 HTML 이면 null)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이 판의 첨부 — id 가 저장할 때마다 바뀐다',
|
||||
type: [MailAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장할 메일 id — 없거나 원 메일이 사라졌으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: 'eaaaaab',
|
||||
})
|
||||
inReplyTo!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
|
||||
example: '2026-10-27T06:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 DraftSaved */
|
||||
export class DraftSavedDto implements SavedDraft {
|
||||
@ApiProperty({ description: '새 임시 저장본 id', example: 'faaaaabj' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '새 판의 첨부 — 클라이언트는 이 id 로 바꿔 쓴다',
|
||||
type: [MailAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC)',
|
||||
example: '2026-10-27T06:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [
|
||||
'directory',
|
||||
'recent',
|
||||
];
|
||||
|
||||
/** 계약 2절 "받는 사람 추천" */
|
||||
export class RecipientSuggestionDto implements RecipientSuggestion {
|
||||
@ApiProperty({
|
||||
description: '표시 이름 (없으면 null)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '이도윤',
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 주소',
|
||||
example: 'doyun.lee@arasystem.it.kr',
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory',
|
||||
enum: SUGGESTION_SOURCES,
|
||||
example: 'directory',
|
||||
})
|
||||
source!: RecipientSuggestion['source'];
|
||||
}
|
||||
|
||||
export class RecipientSuggestionPageDto {
|
||||
@ApiProperty({
|
||||
type: [RecipientSuggestionDto],
|
||||
description:
|
||||
'사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순',
|
||||
})
|
||||
items!: RecipientSuggestionDto[];
|
||||
|
||||
@ApiProperty({ type: PageInfoDto, description: 'total 포함 (걸러진 개수)' })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */
|
||||
export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`,
|
||||
example: '도윤',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '찾는 글자 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_SUGGESTION_QUERY_LENGTH, {
|
||||
message: `찾는 글자는 ${MAX_SUGGESTION_QUERY_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
q?: string;
|
||||
}
|
||||
@@ -76,21 +76,12 @@ export class OutgoingAttachmentDto {
|
||||
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[];
|
||||
|
||||
/**
|
||||
* 발송 본문과 임시 저장 본문이 함께 쓰는 필드 — 계약 2절 "발송 본문"·"임시 저장" (같은 필드·같은 한도).
|
||||
* 받는 사람(to)은 둘의 규칙이 달라(발송 1명 이상 · 임시 저장 0명도 됨) 아래 두 클래스가 따로 둔다 —
|
||||
* class-validator 는 부모의 검사를 자식에서 풀 수 없다.
|
||||
*/
|
||||
export class ComposeContentDto {
|
||||
@ApiPropertyOptional({ description: '참조', type: [RecipientDto] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: '참조 형식이 올바르지 않습니다.' })
|
||||
@@ -172,3 +163,44 @@ export class SendMessageDto {
|
||||
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
|
||||
inReplyTo?: string;
|
||||
}
|
||||
|
||||
/** POST /api/messages — 계약 2절 "발송 본문" */
|
||||
export class SendMessageDto extends ComposeContentDto {
|
||||
@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:
|
||||
'이어 쓴 임시 저장본 id — 보내기에 성공하면(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 서버가 그 초안을 지운다. ' +
|
||||
'지우지 못하면 로그만 남긴다. 내용 지문에는 넣지 않는다 (계약 2절 "임시 저장")',
|
||||
example: 'faaaaabi',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' })
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
/** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */
|
||||
export class DraftBodyDto extends ComposeContentDto {
|
||||
@ApiProperty({
|
||||
description: `받는 사람 (0명도 된다 — 받는 사람·참조·숨은 참조 합계 ${MAX_RECIPIENTS}명 이하)`,
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
to!: RecipientDto[];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
INLINE_IMAGES_MAX_COUNT,
|
||||
INLINE_IMAGE_MAX_BYTES,
|
||||
findReferencedContentIds,
|
||||
hasRealAttachment,
|
||||
isBodyImagePart,
|
||||
normalizeContentId,
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
@@ -10,11 +12,72 @@ import {
|
||||
|
||||
const part = (cid: string, type = 'image/png', size = 1000): InlinePart => ({
|
||||
attachmentId: `blob-${cid}`,
|
||||
attachmentIndex: 0,
|
||||
cid,
|
||||
type,
|
||||
size,
|
||||
});
|
||||
|
||||
/** 본문 그림 판정 (첨부 표시) — 계약 2절 MessageSummary.hasAttachment */
|
||||
describe('본문 그림 · 첨부 표시', () => {
|
||||
/** 우리가 보내는 서명 그림 부분 — disposition inline + Content-ID, 300KB 이하 */
|
||||
const signature = {
|
||||
type: 'image/png',
|
||||
disposition: 'inline',
|
||||
cid: 'sig-3f1c0a2e@arasystem.it.kr',
|
||||
size: 18_342,
|
||||
};
|
||||
|
||||
it('본문 그림 — 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB', () => {
|
||||
expect(isBodyImagePart(signature)).toBe(true);
|
||||
// disposition 이 없어도(Outlook 등) · 형식 대소문자·별칭(image/jpg) · 꺾쇠 붙은 Content-ID · 정확히 300KB
|
||||
expect(isBodyImagePart({ ...signature, disposition: null })).toBe(true);
|
||||
expect(
|
||||
isBodyImagePart({
|
||||
type: 'IMAGE/JPG',
|
||||
disposition: undefined,
|
||||
cid: '<image001.png@01DA>',
|
||||
size: INLINE_IMAGE_MAX_BYTES,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
for (const notBodyImage of [
|
||||
{ ...signature, disposition: 'attachment' },
|
||||
{ ...signature, disposition: ' Attachment ' },
|
||||
{ ...signature, cid: null },
|
||||
{ ...signature, cid: ' <> ' },
|
||||
{ ...signature, type: 'image/svg+xml' },
|
||||
{ ...signature, type: 'application/pdf' },
|
||||
{ ...signature, type: null },
|
||||
{ ...signature, size: INLINE_IMAGE_MAX_BYTES + 1 },
|
||||
{ ...signature, size: 0 },
|
||||
{ ...signature, size: undefined },
|
||||
]) {
|
||||
expect(isBodyImagePart(notBodyImage)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('첨부 표시 — 본문 그림이 아닌 부분이 하나라도 있으면 true, 본문 그림만 있거나 부분이 없으면 false', () => {
|
||||
const pdf = {
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment',
|
||||
cid: null,
|
||||
size: 20_480,
|
||||
};
|
||||
|
||||
expect(hasRealAttachment([])).toBe(false);
|
||||
expect(hasRealAttachment([signature])).toBe(false);
|
||||
expect(
|
||||
hasRealAttachment([signature, { ...signature, cid: 'logo2@x' }]),
|
||||
).toBe(false);
|
||||
expect(hasRealAttachment([signature, pdf])).toBe(true);
|
||||
// 첨부로 붙인 그림(disposition attachment)은 그림이어도 첨부다
|
||||
expect(
|
||||
hasRealAttachment([{ ...signature, disposition: 'attachment' }]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('본문 내장 이미지 정책', () => {
|
||||
it('Content-ID 를 꺾쇠·퍼센트 인코딩·대소문자와 무관하게 맞춘다', () => {
|
||||
expect(normalizeContentId('<Logo@AraSystem.it.kr>')).toBe(
|
||||
|
||||
@@ -55,6 +55,39 @@ export function inlineImageType(type: string): string | null {
|
||||
return INLINE_IMAGE_TYPES.get(type.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
/** 첨부 부분의 모양 — 본문 그림인지 가릴 때 보는 것만 (엔진과 무관한 모양) */
|
||||
export interface AttachmentPartInfo {
|
||||
readonly type?: string | null;
|
||||
readonly disposition?: string | null;
|
||||
readonly cid?: string | null;
|
||||
readonly size?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 그림인가 — 메일을 열면 본문 안에 그려질 수 있는 부분이라 첨부 표시(hasAttachment)에서 뺀다.
|
||||
* 모두 맞아야 한다: 넣을 수 있는 그림 형식(inlineImageType) · Content-ID 가 있음 · disposition 이 `attachment` 가 아님(inline 이거나 없음) ·
|
||||
* 크기 1바이트 이상 INLINE_IMAGE_MAX_BYTES 이하 (selectInlineImages 가 넣을 후보로 고르는 기준과 같다).
|
||||
* 본문이 실제로 가리키는지는 보지 않는다 — 목록은 본문을 읽지 않는다.
|
||||
* 우리가 보내는 서명 그림(disposition inline + cid, 300KB 이하)은 늘 본문 그림이다.
|
||||
*/
|
||||
export function isBodyImagePart(part: AttachmentPartInfo): boolean {
|
||||
const size = part.size ?? 0;
|
||||
return (
|
||||
inlineImageType(part.type ?? '') !== null &&
|
||||
normalizeContentId(part.cid ?? '') !== '' &&
|
||||
(part.disposition ?? '').trim().toLowerCase() !== 'attachment' &&
|
||||
size > 0 &&
|
||||
size <= INLINE_IMAGE_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
/** 첨부가 있는 메일인가 — 본문 그림이 아닌 부분이 하나라도 있으면 true (본문 그림만 있으면 첨부 없는 메일) */
|
||||
export function hasRealAttachment(
|
||||
parts: readonly AttachmentPartInfo[],
|
||||
): boolean {
|
||||
return parts.some((part) => !isBodyImagePart(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한.
|
||||
* (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다)
|
||||
|
||||
@@ -106,7 +106,11 @@ describe('sanitizeMailHtml', () => {
|
||||
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
|
||||
expect(result).toEqual({
|
||||
html: '<b>y</b>',
|
||||
hasBlockedImages: false,
|
||||
embeddedContentIds: new Set(),
|
||||
});
|
||||
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
|
||||
expect(cpuMs()).toBeLessThan(2_000);
|
||||
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
|
||||
@@ -190,6 +194,28 @@ describe('sanitizeMailHtml', () => {
|
||||
|
||||
expect(result.html).toBe('<p>a</p>');
|
||||
expect(result.hasBlockedImages).toBe(false);
|
||||
expect(result.embeddedContentIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('결과에 그림으로 실제로 넣은 Content-ID 만 알려 준다 — 버린 영역 안·img 가 아닌 곳의 참조는 들지 않는다 (첨부의 isInline)', () => {
|
||||
const inline = new Map([
|
||||
['logo@x', PNG_DATA_URI],
|
||||
['hidden@x', PNG_DATA_URI],
|
||||
['background@x', PNG_DATA_URI],
|
||||
['unused@x', PNG_DATA_URI],
|
||||
]);
|
||||
|
||||
const result = sanitizeMailHtml(
|
||||
'<p><img src="CID:Logo@X"><img src="cid:logo@x"></p>' +
|
||||
'<svg><img src="cid:hidden@x"></svg>' +
|
||||
'<div style="background:url(cid:background@x)">배경</div>',
|
||||
inline,
|
||||
);
|
||||
|
||||
expect(result.embeddedContentIds).toEqual(new Set(['logo@x']));
|
||||
expect(result.html).toBe(
|
||||
`<p><img src="${PNG_DATA_URI}" /><img src="${PNG_DATA_URI}" /></p><div>배경</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => {
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface SanitizedMailHtml {
|
||||
readonly html: string;
|
||||
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
|
||||
readonly hasBlockedImages: boolean;
|
||||
/**
|
||||
* 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값).
|
||||
* 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다
|
||||
*/
|
||||
readonly embeddedContentIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
@@ -294,6 +299,7 @@ function resolveImage(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
inlineImages: ReadonlyMap<string, string>,
|
||||
markBlocked: () => void,
|
||||
markEmbedded: (contentId: string) => void,
|
||||
): sanitizeHtml.Attributes {
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
delete next.src;
|
||||
@@ -305,8 +311,13 @@ function resolveImage(
|
||||
|
||||
const src = attribs.src?.trim() ?? '';
|
||||
if (/^cid:/i.test(src)) {
|
||||
const dataUri = inlineImages.get(normalizeContentId(src.slice(4)));
|
||||
if (dataUri) next.src = dataUri;
|
||||
const contentId = normalizeContentId(src.slice(4));
|
||||
const dataUri = inlineImages.get(contentId);
|
||||
if (dataUri) {
|
||||
next.src = dataUri;
|
||||
// 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다
|
||||
markEmbedded(contentId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (SAFE_DATA_IMAGE.test(src)) {
|
||||
@@ -338,6 +349,10 @@ export function sanitizeMailHtml(
|
||||
const markBlocked = () => {
|
||||
hasBlockedImages = true;
|
||||
};
|
||||
const embeddedContentIds = new Set<string>();
|
||||
const markEmbedded = (contentId: string) => {
|
||||
embeddedContentIds.add(contentId);
|
||||
};
|
||||
|
||||
const output = sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
@@ -352,7 +367,12 @@ export function sanitizeMailHtml(
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
|
||||
img: (tagName, attribs) => {
|
||||
const next = resolveImage(attribs, inlineImages, markBlocked);
|
||||
const next = resolveImage(
|
||||
attribs,
|
||||
inlineImages,
|
||||
markBlocked,
|
||||
markEmbedded,
|
||||
);
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
|
||||
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
|
||||
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
|
||||
@@ -370,5 +390,5 @@ export function sanitizeMailHtml(
|
||||
},
|
||||
});
|
||||
|
||||
return { html: output, hasBlockedImages };
|
||||
return { html: output, hasBlockedImages, embeddedContentIds };
|
||||
}
|
||||
|
||||
@@ -506,6 +506,199 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "서식 본문"의 그림 예외 — 보내는 사람 자신의 서명 그림만 남는다 */
|
||||
describe('서명 그림', () => {
|
||||
const MINE = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const NOT_MINE = '00000000-0000-4000-8000-000000000000';
|
||||
const options = { signatureImageIds: new Set([MINE]) };
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
it('내 서명 그림은 src·width·alt 만 남긴다 (style·그 밖의 속성은 지운다)', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" style="border: 1px solid #000" height="50" class="x" onerror="x()">`,
|
||||
options,
|
||||
),
|
||||
).toBe(
|
||||
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it('주소의 대소문자·앞뒤 공백은 정규화한다 — 지문이 같은 그림에 같은 값이 되게', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<img src=" /api/mail-signature/images/${MINE.toUpperCase()} " width=" 96 ">`,
|
||||
options,
|
||||
),
|
||||
).toBe(`<img src="${src(MINE)}" width="96" />`);
|
||||
});
|
||||
|
||||
it('width 는 1~600 정수만, alt 는 200자까지 — 형식이 틀리면 그 속성만 뺀다', () => {
|
||||
for (const width of [
|
||||
'0',
|
||||
'601',
|
||||
'96.5',
|
||||
'96px',
|
||||
'50%',
|
||||
'096',
|
||||
'-1',
|
||||
'',
|
||||
]) {
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" width="${width}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" />`);
|
||||
}
|
||||
expect(clean(`<img src="${src(MINE)}" width="600">`, options)).toBe(
|
||||
`<img src="${src(MINE)}" width="600" />`,
|
||||
);
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}" />`);
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(201)}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" />`);
|
||||
});
|
||||
|
||||
it('남의 그림·찾지 못한 그림·서명 그림이 아닌 주소는 태그째 지운다', () => {
|
||||
for (const img of [
|
||||
`<img src="${src(NOT_MINE)}" width="96">`,
|
||||
`<img src="https://portal.example${src(MINE)}">`,
|
||||
`<img src="${src(MINE)}?v=2">`,
|
||||
`<img src="/api/files/${MINE}">`,
|
||||
'<img src="cid:logo@x">',
|
||||
'<img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
'<img src="https://evil.example/pixel.gif">',
|
||||
'<img>',
|
||||
]) {
|
||||
expect(clean(`<p>앞${img}뒤</p>`, options)).toBe('<p>앞뒤</p>');
|
||||
}
|
||||
});
|
||||
|
||||
it('그림 id 를 주지 않으면 서명 그림도 지운다 (기본값 — 그림 없음)', () => {
|
||||
expect(clean(`<p>앞<img src="${src(MINE)}">뒤</p>`)).toBe('<p>앞뒤</p>');
|
||||
});
|
||||
|
||||
it('지운 그림 뒤의 형제 태그는 제 이름으로 닫힌다', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<p><img src="${src(NOT_MINE)}"><strong>굵게</strong><img src="${src(MINE)}"><em>기울임</em></p>`,
|
||||
options,
|
||||
),
|
||||
).toBe(
|
||||
`<p><strong>굵게</strong><img src="${src(MINE)}" /><em>기울임</em></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('한 번 더 정화해도 그대로이고, 받은 메일 정화를 거쳐도 그림이 남는다 (임시 저장본을 다시 읽을 때)', () => {
|
||||
const once = clean(
|
||||
`<div><p>서명</p><img src="${src(MINE)}" width="144" alt=""></div>`,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(clean(once, options)).toBe(once);
|
||||
// 받은 메일 정화는 서명 그림 주소(상대 주소)를 외부 이미지로 보지 않고 지운다 — 우리 주소는 cid 로 실려 나간다
|
||||
expect(sanitizeMailHtml(once)).toEqual({
|
||||
html: '<div><p>서명</p></div>',
|
||||
hasBlockedImages: false,
|
||||
embeddedContentIds: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 그림(img) 속성 표본 — frontend/src/test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES 와 **같은 표**다 (행과 순서까지 그대로).
|
||||
* [속성, 값, 서버가 남기는가] — src 가 남지 않으면 그림째 지우고, width · alt 가 남지 않으면 그 속성만 지운다.
|
||||
* 표의 id 는 보내는 사람의 그림이다 (서버는 여기에 더해 주인을 본다 — 위 테스트들).
|
||||
* ⚠️ 그림 규칙을 바꾸면 두 표(이 표·outgoing-style-samples.ts)와 두 규칙 파일(sanitize-outgoing-html.ts·프론트 signature-image.ts)을 함께 고친다.
|
||||
*/
|
||||
describe('그림 속성 표본 — 프론트 편집기와 같은 표', () => {
|
||||
const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const IMAGE_SAMPLES: readonly (readonly [
|
||||
attribute: 'src' | 'width' | 'alt',
|
||||
value: string,
|
||||
kept: boolean,
|
||||
])[] = [
|
||||
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
[
|
||||
'src',
|
||||
`/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`,
|
||||
true,
|
||||
],
|
||||
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
|
||||
['src', '', false],
|
||||
['src', '/api/mail-signature/images/', false],
|
||||
['src', '/api/mail-signature/images/not-a-uuid', false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
|
||||
[
|
||||
'src',
|
||||
`https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'src',
|
||||
`//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
|
||||
false,
|
||||
],
|
||||
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
|
||||
['src', 'https://evil.example/t.png', false],
|
||||
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
|
||||
['width', '1', true],
|
||||
['width', '96', true],
|
||||
['width', '144', true],
|
||||
['width', '600', true],
|
||||
['width', ' 144 ', true],
|
||||
['width', '0', false],
|
||||
['width', '601', false],
|
||||
['width', '1000', false],
|
||||
['width', '096', false],
|
||||
['width', '-1', false],
|
||||
['width', '+5', false],
|
||||
['width', '14.5', false],
|
||||
['width', '144px', false],
|
||||
['width', '50%', false],
|
||||
['width', '1e2', false],
|
||||
['width', '', false],
|
||||
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
|
||||
['alt', '', true],
|
||||
['alt', '회사 로고', true],
|
||||
['alt', 'ㄱ'.repeat(200), true],
|
||||
['alt', 'ㄱ'.repeat(201), false],
|
||||
];
|
||||
/** 서버가 다시 쓰는 정식 주소 (소문자 경로 · 소문자 id) */
|
||||
const canonical = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`;
|
||||
const mine = { signatureImageIds: new Set([SAMPLE_IMAGE_ID]) };
|
||||
|
||||
it.each(IMAGE_SAMPLES)(
|
||||
'%s="%s" — 서버가 남기는가 %s',
|
||||
(attribute, value, kept) => {
|
||||
if (attribute === 'src') {
|
||||
// 주소가 남지 않으면 그림째 지운다
|
||||
expect(clean(`<p>앞<img src="${value}">뒤</p>`, mine)).toBe(
|
||||
kept ? `<p>앞<img src="${canonical}" />뒤</p>` : '<p>앞뒤</p>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 폭·설명은 그 속성만 — 남으면 폭은 앞뒤 공백을 뗀 값, 설명은 그대로
|
||||
const left = attribute === 'width' ? value.trim() : value;
|
||||
expect(
|
||||
clean(`<img src="${canonical}" ${attribute}="${value}">`, mine),
|
||||
).toBe(
|
||||
kept
|
||||
? `<img src="${canonical}" ${attribute}="${left}" />`
|
||||
: `<img src="${canonical}" />`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 실제 편집기가 보내는 모양 — 브라우저가 style 을 정규화해 쓴다(값 끝의 `;`·쉼표 뒤 공백·따옴표 없는 한글 글꼴 이름).
|
||||
* 보내는 정화와 받은 메일 정화를 모두 지나도 남아야 한다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
import { parseSignatureImageId, signatureImagePath } from './signature-images';
|
||||
|
||||
/**
|
||||
* 보내는 메일의 서식 본문 정화 (순수 함수) — 계약 2절 "서식 본문 (bodyHtml)".
|
||||
@@ -17,7 +18,10 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
* 그렇게 숨긴 글이 보이지 않는 채로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
|
||||
* (받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 모양은 그대로 둔다)
|
||||
* - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다
|
||||
* - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로)
|
||||
* - 그림은 **보내는 사람 자신의 서명 그림만** — `<img src="/api/mail-signature/images/{id}">` 이고 그 id 가
|
||||
* `signatureImageIds`(그 사람의 그림)에 있을 때. 속성은 src·width(1~600 정수)·alt(200자 이하)만 남기고, 그 밖의 img 는 태그째 지운다
|
||||
* (html/signature-images.ts — 보낼 때 서비스가 주소를 `cid:` 로 바꿔 메일 안에 함께 싣는다)
|
||||
* - 폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (붙여 넣은 그림은 첨부로 — 서명 그림만 예외)
|
||||
*
|
||||
* 같은 입력이면 늘 같은 결과다 — 발송 중복 방지의 내용 지문이 이 결과를 쓴다 (send-idempotency.ts).
|
||||
* 우리 웹메일이 다시 읽을 때는 받은 메일 정화를 한 번 더 거친다 — 여기서 남긴 서식은 그쪽 목록에도 모두 있다
|
||||
@@ -64,6 +68,8 @@ const ALLOWED_TAGS = [
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
// 서명 그림만 — 아래 keepSignatureImage 가 그 밖의 img 를 태그째 지운다
|
||||
'img',
|
||||
];
|
||||
|
||||
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
@@ -72,8 +78,21 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
ol: ['start'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
img: ['src', 'width', 'alt'],
|
||||
};
|
||||
|
||||
/** 서명 그림의 표시 폭 상한 (px) — 계약 2절 "서명" (작게 96 · 보통 144 · 원본은 원래 폭, 600 이하) */
|
||||
export const MAX_SIGNATURE_IMAGE_WIDTH = 600;
|
||||
/** 서명 그림의 대체 글자 상한 (JS 문자열 길이) */
|
||||
export const MAX_SIGNATURE_IMAGE_ALT_LENGTH = 200;
|
||||
/** width 값 — 앞자리 0 없는 1~3자리 정수 (600 이하는 따로 본다) */
|
||||
const IMAGE_WIDTH = /^[1-9]\d{0,2}$/;
|
||||
|
||||
export interface OutgoingSanitizeOptions {
|
||||
/** 남겨도 되는 서명 그림 id — 보내는 사람 자신의 그림(소문자 UUID). 없으면 그림을 하나도 남기지 않는다 */
|
||||
readonly signatureImageIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// ── style 값 형식 ───────────────────────────────────────────────────────────
|
||||
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
|
||||
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다
|
||||
@@ -235,11 +254,49 @@ function keepSafeLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
|
||||
: { tagName: 'span', attribs: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림만 남긴다 — src 가 정확히 `/api/mail-signature/images/{id}` 이고 그 id 가 보내는 사람의 그림일 때.
|
||||
* 속성은 src(정규화한 주소)·width(1~600 정수)·alt(200자 이하)만 — style 도 남기지 않는다. 형식이 틀린 width·alt 는 그 속성만 뺀다.
|
||||
* 그 밖의 img 는 태그째 지운다 — 이름을 '' 로 바꾸면 허용 목록에 없어 버려진다(img 는 빈 요소라 버릴 내용도 없다).
|
||||
* '' 인 이유는 받은 메일 정화(sanitize-mail-html.ts 의 img)와 같다 — 버린 태그의 바뀐 이름이 다음 형제를 닫는 데 쓰이지 않게.
|
||||
*/
|
||||
function keepSignatureImage(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
allowedIds: ReadonlySet<string>,
|
||||
): sanitizeHtml.Tag {
|
||||
const id = parseSignatureImageId(attribs.src);
|
||||
if (id === null || !allowedIds.has(id)) return { tagName: '', attribs: {} };
|
||||
|
||||
const next: sanitizeHtml.Attributes = { src: signatureImagePath(id) };
|
||||
const width = attribs.width?.trim();
|
||||
if (
|
||||
width !== undefined &&
|
||||
IMAGE_WIDTH.test(width) &&
|
||||
Number(width) <= MAX_SIGNATURE_IMAGE_WIDTH
|
||||
) {
|
||||
next.width = width;
|
||||
}
|
||||
if (
|
||||
attribs.alt !== undefined &&
|
||||
attribs.alt.length <= MAX_SIGNATURE_IMAGE_ALT_LENGTH
|
||||
) {
|
||||
next.alt = attribs.alt;
|
||||
}
|
||||
return { tagName: 'img', attribs: next };
|
||||
}
|
||||
|
||||
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
|
||||
|
||||
/**
|
||||
* 서식 본문을 정화한다. 결과가 비면(서식 없는 빈 입력·모두 지워진 경우) 빈 문자열이다 —
|
||||
* 그때 서비스는 텍스트 메일로 보낸다.
|
||||
* 같은 입력·같은 `signatureImageIds` 면 늘 같은 결과다 (발송 지문).
|
||||
*/
|
||||
export function sanitizeOutgoingHtml(html: string): string {
|
||||
export function sanitizeOutgoingHtml(
|
||||
html: string,
|
||||
options: OutgoingSanitizeOptions = {},
|
||||
): string {
|
||||
const signatureImageIds = options.signatureImageIds ?? NO_SIGNATURE_IMAGES;
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
@@ -263,6 +320,8 @@ export function sanitizeOutgoingHtml(html: string): string {
|
||||
tagName,
|
||||
attribs: keepNumbers(attribs, ['colspan', 'rowspan']),
|
||||
}),
|
||||
img: (_tagName, attribs) =>
|
||||
keepSignatureImage(attribs, signatureImageIds),
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
parseSignatureImageId,
|
||||
rewriteSignatureImages,
|
||||
signatureImagePath,
|
||||
} from './signature-images';
|
||||
|
||||
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const OTHER = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
describe('서명 그림 주소 (signature-images)', () => {
|
||||
it('주소가 정확히 /api/mail-signature/images/{uuid} 일 때만 id 를 준다 (대소문자 무시, 소문자로)', () => {
|
||||
expect(parseSignatureImageId(signatureImagePath(ID))).toBe(ID);
|
||||
expect(
|
||||
parseSignatureImageId(
|
||||
` /api/mail-signature/images/${ID.toUpperCase()} `,
|
||||
),
|
||||
).toBe(ID);
|
||||
|
||||
for (const src of [
|
||||
undefined,
|
||||
'',
|
||||
`https://portal.example/api/mail-signature/images/${ID}`,
|
||||
`//portal.example/api/mail-signature/images/${ID}`,
|
||||
`/api/mail-signature/images/${ID}?x=1`,
|
||||
`/api/mail-signature/images/${ID}#a`,
|
||||
`/api/mail-signature/images/${ID}/../x`,
|
||||
`/api/mail-signature/images/not-a-uuid`,
|
||||
`/api/files/${ID}`,
|
||||
`cid:${ID}`,
|
||||
]) {
|
||||
expect(parseSignatureImageId(src)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('정화한 HTML 이 가리키는 그림 id 를 나온 순서대로, 겹치지 않게 찾는다', () => {
|
||||
const html =
|
||||
`<p>a</p><img src="${signatureImagePath(OTHER)}" width="96" />` +
|
||||
`<img src="${signatureImagePath(ID)}" alt="" />` +
|
||||
`<img src="${signatureImagePath(OTHER)}" />`;
|
||||
|
||||
expect(findSignatureImageIds(html)).toEqual([OTHER, ID]);
|
||||
expect(findSignatureImageIds('<p>그림 없음</p>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('주소를 바꾸고(cid 등) 나머지 속성은 그대로 둔다 — 실을 수 없는 그림은 태그째 지운다', () => {
|
||||
const html =
|
||||
`<p>서명</p><img src="${signatureImagePath(ID)}" width="144" alt="로고" />` +
|
||||
`<img src="${signatureImagePath(OTHER)}" />`;
|
||||
|
||||
expect(
|
||||
rewriteSignatureImages(html, (id) =>
|
||||
id === ID ? `cid:sig-${id}@arasystem.test` : null,
|
||||
),
|
||||
).toBe(
|
||||
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="로고" />`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 서명 그림 주소 규칙 (순수 함수) — 계약 2절 "서식 본문"의 그림 예외 · "서명".
|
||||
*
|
||||
* 서식 본문에 남는 그림은 보내는 사람 자신의 서명 그림 `<img src="/api/mail-signature/images/{id}">` 뿐이다.
|
||||
* - 임시 저장본은 이 주소 그대로 둔다 (편집기가 그 주소로 그림을 다시 불러온다)
|
||||
* - 보낼 때는 서버가 그림을 메일 안에 함께 실어(`multipart/related`) 주소를 `cid:` 로 바꾼다 — 받는 쪽에서 외부 이미지처럼 막히지 않게
|
||||
*
|
||||
* ⚠️ 아래 정규식은 **보내는 정화를 거친 HTML** 에만 쓴다 — 정화가 img 를 늘 `<img src="…" width="…" alt="…" />` 순서로 다시 쓴다
|
||||
* (sanitize-outgoing-html.ts). 남이 만든 HTML 에 직접 쓰지 않는다.
|
||||
*/
|
||||
|
||||
/** 서명 그림 주소 앞부분 — 컨트롤러 경로(`/api/mail-signature/images/{id}`)와 같다 */
|
||||
export const SIGNATURE_IMAGE_PATH_PREFIX = '/api/mail-signature/images/';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID (소문자 16진) */
|
||||
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||
|
||||
/** 그림 주소 전체 — 앞뒤에 다른 글자가 붙으면(쿼리·조각·다른 경로) 서명 그림이 아니다. 대소문자는 가리지 않는다 */
|
||||
const SIGNATURE_IMAGE_SRC = new RegExp(
|
||||
`^${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})$`,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** 정화한 HTML 안의 서명 그림 태그 — 정화가 src 를 맨 앞에 쓴다 */
|
||||
const SANITIZED_SIGNATURE_IMG = new RegExp(
|
||||
`<img src="${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})"([^>]*)>`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** 그림 id 의 주소 */
|
||||
export function signatureImagePath(id: string): string {
|
||||
return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
/** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */
|
||||
export function parseSignatureImageId(src: string | undefined): string | null {
|
||||
const match = src?.trim().match(SIGNATURE_IMAGE_SRC);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** 정화한 HTML 이 가리키는 서명 그림 id — 나온 순서대로, 겹치지 않게 */
|
||||
export function findSignatureImageIds(sanitizedHtml: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const match of sanitizedHtml.matchAll(SANITIZED_SIGNATURE_IMG)) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정화한 HTML 의 서명 그림 주소를 바꾼다 — `resolve` 가 돌려준 값(예: `cid:…`)으로, null 이면 그 img 를 지운다
|
||||
* (실을 수 없는 그림 — 그사이 지운 그림 등). 나머지 속성(width·alt)은 그대로 둔다.
|
||||
*/
|
||||
export function rewriteSignatureImages(
|
||||
sanitizedHtml: string,
|
||||
resolve: (id: string) => string | null,
|
||||
): string {
|
||||
return sanitizedHtml.replace(
|
||||
SANITIZED_SIGNATURE_IMG,
|
||||
(_whole, id: string, rest: string) => {
|
||||
const src = resolve(id);
|
||||
return src === null ? '' : `<img src="${src}"${rest}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,11 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
||||
export const JMAP_SEND_TIMEOUT_MS = 40_000;
|
||||
/** 첨부 업로드 제한 시간 — 임시 파일에서 엔진으로 흘려보낸다 (내부망 기준 20MB 에 충분한 여유) */
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
/**
|
||||
* 발송 중에 서명 그림(한 장 300KB 이하)을 올리는 제한 시간 — 발송 전체가 클라이언트 한도(60초) 안에 답하도록 짧게.
|
||||
* 발송 경로는 남은 시간이 이보다 적으면 그만큼만 기다린다 (jmap-mail.provider.ts 의 SEND_TOTAL_BUDGET_MS).
|
||||
*/
|
||||
export const JMAP_INLINE_UPLOAD_TIMEOUT_MS = 15_000;
|
||||
/** 다운로드는 응답 헤더까지만 제한한다 — 본문은 클라이언트로 흘려보내는 중이라 시간을 정할 수 없다 */
|
||||
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
|
||||
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
|
||||
@@ -432,6 +437,7 @@ export class JmapClient {
|
||||
user: string,
|
||||
content: Blob,
|
||||
type: string,
|
||||
options: { readonly timeoutMs?: number } = {},
|
||||
): Promise<{ blobId: string; type: string; size: number }> {
|
||||
const session = await this.getSession(user);
|
||||
const path = expandTemplate(session.uploadPath, {
|
||||
@@ -448,7 +454,7 @@ export class JmapClient {
|
||||
},
|
||||
body: content,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
options.timeoutMs ?? UPLOAD_TIMEOUT_MS,
|
||||
'upload',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
@@ -79,10 +79,22 @@ describe('JMAP 오류 매핑', () => {
|
||||
properties: ['attachments/2/blobId'],
|
||||
}),
|
||||
).toBe(true);
|
||||
// 서명 그림을 실을 때(bodyStructure) — Stalwart 는 부분 위치 없이 알려 준다
|
||||
for (const property of [
|
||||
'bodyStructure/blobId',
|
||||
'bodyStructure/subParts/1/blobId',
|
||||
]) {
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: [property],
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: ['attachments/type', 'to/0/email'],
|
||||
properties: ['attachments/type', 'to/0/email', 'bodyStructure/type'],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isMissingAttachmentBlob({ type: 'overQuota' })).toBe(false);
|
||||
|
||||
@@ -31,8 +31,14 @@ 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 참조 속성 — `attachments/blobId`, `attachments/0/blobId`.
|
||||
* 서명 그림을 함께 실을 때는 구조를 직접 적어(bodyStructure) 첨부도 그 안에 든다 — Stalwart(v0.16)는 그 안의 없는 blob 을
|
||||
* 부분 위치 없이 `bodyStructure/blobId` 로 알려 준다 (2026-10-27 개발 엔진으로 확인). 이 경로에서 blob 을 찾지 못하는 것은
|
||||
* 첨부뿐이다 — 서명 그림은 같은 요청 직전에 올린다.
|
||||
*/
|
||||
const ATTACHMENT_BLOB_PROPERTY =
|
||||
/^(?:attachments(?:\/\d+)?|bodyStructure(?:\/subParts\/\d+)*)\/blobId$/;
|
||||
|
||||
/**
|
||||
* 첨부 blob 을 엔진에서 찾지 못한 오류인지 — MAIL_004 로 응답한다.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
|
||||
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
|
||||
import type {
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
@@ -23,7 +24,11 @@ import type {
|
||||
* 엔진 응답은 외부 입력이므로 모든 필드를 방어적으로 읽는다.
|
||||
*/
|
||||
|
||||
/** 목록·요약에 필요한 Email 속성 */
|
||||
/**
|
||||
* 목록·요약에 필요한 Email 속성.
|
||||
* 엔진의 hasAttachment 대신 첨부 부분(attachments)을 읽어 우리가 정한다 — 본문 그림(서명 로고 등)만 있는 메일은 첨부 없음
|
||||
* (html/inline-images.ts 의 hasRealAttachment). 부분에서는 SUMMARY_BODY_PART_PROPERTIES 만 읽는다.
|
||||
*/
|
||||
export const SUMMARY_PROPERTIES = [
|
||||
'id',
|
||||
'threadId',
|
||||
@@ -34,23 +39,33 @@ export const SUMMARY_PROPERTIES = [
|
||||
'subject',
|
||||
'preview',
|
||||
'receivedAt',
|
||||
'hasAttachment',
|
||||
'attachments',
|
||||
'size',
|
||||
];
|
||||
|
||||
/** 상세에 필요한 Email 속성 */
|
||||
/** 목록·요약에서 첨부 부분마다 읽는 속성 — 첨부 표시(본문 그림인지) 판정에 쓰는 것만 */
|
||||
export const SUMMARY_BODY_PART_PROPERTIES = [
|
||||
'type',
|
||||
'disposition',
|
||||
'cid',
|
||||
'size',
|
||||
];
|
||||
|
||||
/** 상세에 필요한 Email 속성 (겹치는 이름은 한 번만 — attachments) */
|
||||
export const DETAIL_PROPERTIES = [
|
||||
...SUMMARY_PROPERTIES,
|
||||
'cc',
|
||||
'bcc',
|
||||
'replyTo',
|
||||
'sentAt',
|
||||
'messageId',
|
||||
'references',
|
||||
'bodyValues',
|
||||
'htmlBody',
|
||||
'textBody',
|
||||
'attachments',
|
||||
...new Set([
|
||||
...SUMMARY_PROPERTIES,
|
||||
'cc',
|
||||
'bcc',
|
||||
'replyTo',
|
||||
'sentAt',
|
||||
'messageId',
|
||||
'references',
|
||||
'bodyValues',
|
||||
'htmlBody',
|
||||
'textBody',
|
||||
'attachments',
|
||||
]),
|
||||
];
|
||||
|
||||
/** 본문 부분(part)에서 읽는 속성 */
|
||||
@@ -166,7 +181,7 @@ export function toSummary(email: JmapEmail): MessageSummary {
|
||||
receivedAt: toUtcIso(email.receivedAt) ?? new Date(0).toISOString(),
|
||||
isRead: email.keywords?.$seen === true,
|
||||
isFlagged: email.keywords?.$flagged === true,
|
||||
hasAttachment: email.hasAttachment === true,
|
||||
hasAttachment: hasRealAttachment(email.attachments ?? []),
|
||||
size: email.size ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -233,14 +248,19 @@ function joinBodyValues(
|
||||
|
||||
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,
|
||||
}));
|
||||
const inlineParts: InlinePart[] = attachments.flatMap((part, index) =>
|
||||
typeof part.cid === 'string' && part.cid.trim() !== ''
|
||||
? [
|
||||
{
|
||||
attachmentId: part.blobId,
|
||||
attachmentIndex: index,
|
||||
cid: part.cid.trim(),
|
||||
type: partType(part),
|
||||
size: part.size ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const html = joinBodyValues(email, email.htmlBody, 'text/html');
|
||||
const text = joinBodyValues(email, email.textBody, 'text/plain');
|
||||
|
||||
|
||||
@@ -102,11 +102,17 @@ export interface JmapIdentity {
|
||||
}
|
||||
|
||||
export interface JmapGetResult<T> {
|
||||
/** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */
|
||||
state?: string;
|
||||
list?: T[];
|
||||
notFound?: string[];
|
||||
}
|
||||
|
||||
export interface JmapSetResult {
|
||||
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
|
||||
oldState?: string | null;
|
||||
/** 이 요청이 바꾼 뒤의 상태 */
|
||||
newState?: string;
|
||||
created?: Record<string, { id?: string }>;
|
||||
updated?: Record<string, unknown>;
|
||||
destroyed?: string[];
|
||||
|
||||
@@ -36,9 +36,21 @@ export const MAX_BODY_VALUE_BYTES = 2_000_000;
|
||||
*/
|
||||
export const MESSAGES_ROUTE = 'messages';
|
||||
|
||||
/**
|
||||
* 임시 저장 라우트 경로 (`/api/drafts`) — 컨트롤러와, 저장 요청의 본문 한도를 발송과 같게 늘리는 파서(app.setup.ts)가 같이 쓴다.
|
||||
*/
|
||||
export const DRAFTS_ROUTE = 'drafts';
|
||||
|
||||
/** 검색어 최대 길이 */
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
|
||||
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
|
||||
|
||||
/** 받는 사람 추천 — 주고받은 주소를 모으는 최근 메일 수 (보낸편지함·받은편지함 각각) */
|
||||
export const SUGGESTION_SENT_SCAN = 500;
|
||||
export const SUGGESTION_RECEIVED_SCAN = 500;
|
||||
|
||||
/** 받는 사람 표시 이름 최대 길이 */
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from '@nestjs/platform-express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
|
||||
import { isAllowedAttachment } from './attachment.policy';
|
||||
import { DraftController } from './draft.controller';
|
||||
import { JmapClient } from './jmap/jmap.client';
|
||||
import { JmapMailProvider } from './jmap/jmap-mail.provider';
|
||||
import { MailAttachmentController } from './mail-attachment.controller';
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
} from './mail.settings';
|
||||
import { MailboxController, MailQuotaController } from './mailbox.controller';
|
||||
import { MessageController } from './message.controller';
|
||||
import { RecipientSuggestionController } from './recipient-suggestion.controller';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
|
||||
function readMailSettings(config: ConfigService): MailSettings {
|
||||
return buildMailSettings((key) => config.get<string>(key));
|
||||
@@ -74,6 +79,9 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
|
||||
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
|
||||
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
|
||||
*
|
||||
* 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 —
|
||||
* 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -81,12 +89,18 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
inject: [ConfigService],
|
||||
useFactory: attachmentUploadOptions,
|
||||
}),
|
||||
// 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명")
|
||||
MailSignatureModule,
|
||||
// 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천")
|
||||
MailDirectoryModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MessageController,
|
||||
MailAttachmentController,
|
||||
DraftController,
|
||||
RecipientSuggestionController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -97,6 +111,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
JmapClient,
|
||||
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
|
||||
MailService,
|
||||
RecipientSuggestionService,
|
||||
MailUploadTempCleanup,
|
||||
],
|
||||
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AttachmentContent,
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MailAccountProfile,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
@@ -10,6 +13,7 @@ import type {
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
SentMessageRecord,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
@@ -104,4 +108,35 @@ export interface MailProvider {
|
||||
attachmentId: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null>;
|
||||
|
||||
/**
|
||||
* 임시 저장 (계약 2절 "임시 저장") — 임시보관함에 새 초안을 만든다. `replaceId` 가 있으면 그 초안을 바꾼다:
|
||||
* - 메일은 고칠 수 없어 새 초안을 **만든 뒤에** 옛것을 지운다 — 만들기가 실패하면 옛것은 그대로다
|
||||
* - `replaceId` 가 임시보관함의 사용자 초안이 아니면 아무것도 만들지 않고 RES_001
|
||||
* - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 바꿨거나 지웠음) 방금 만든 것을 지우고 MAIL_008
|
||||
* 발송 중복 방지용 Message-ID·지문·발송 중 표시는 넣지 않는다. 첨부 합계·없는 첨부는 발송과 같다(FILE_002·MAIL_004).
|
||||
*/
|
||||
saveDraft(
|
||||
user: string,
|
||||
content: DraftContent,
|
||||
replaceId?: string,
|
||||
): Promise<SavedDraft>;
|
||||
|
||||
/** 쓰기 화면이 이어 쓸 임시 저장본 — 임시보관함의 사용자 초안이 아니면(발송 중에 생긴 내부 초안 포함) RES_001 */
|
||||
getDraft(user: string, id: string): Promise<DraftSource>;
|
||||
|
||||
/**
|
||||
* 임시 저장본을 지운다 (보낸 뒤) — 사용자 초안만 지운다. 이미 없거나 초안이 아니면 아무것도 하지 않는다
|
||||
* (클라이언트가 보낸 id 로 다른 메일을 지우지 않게).
|
||||
*/
|
||||
deleteDraft(user: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 주고받은 주소 (받는 사람 추천) — 그 직원의 보낸편지함 최근 `sent` 통의 받는 사람·참조·숨은참조와
|
||||
* 받은편지함 최근 `received` 통의 보낸 사람. 다른 직원의 메일함은 읽지 않는다.
|
||||
*/
|
||||
listCorrespondents(
|
||||
user: string,
|
||||
limits: { readonly sent: number; readonly received: number },
|
||||
): Promise<Correspondence[]>;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SendMessageDto } from './dto/send-message.dto';
|
||||
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
|
||||
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
|
||||
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
} from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
import { DRAFT_CLEANUP_WAIT_MS, MailService } from './mail.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
import type { MessageDetailSource } from './mail.types';
|
||||
import type { DraftSource, MessageDetailSource } from './mail.types';
|
||||
import type { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
@@ -62,9 +65,51 @@ function fakeProvider(): ProviderMocks {
|
||||
uploadAttachment: mockOf<'uploadAttachment'>(),
|
||||
openAttachment: mockOf<'openAttachment'>(),
|
||||
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
|
||||
saveDraft: mockOf<'saveDraft'>().mockResolvedValue({
|
||||
id: 'draft-2',
|
||||
attachments: [],
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
}),
|
||||
getDraft: mockOf<'getDraft'>(),
|
||||
deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined),
|
||||
listCorrespondents: mockOf<'listCorrespondents'>(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 메일 서비스가 쓰는 서명 서비스의 두 메서드 — 그림 id 목록과 그림 읽기 */
|
||||
interface SignatureMocks {
|
||||
imageIdsOf: jest.Mock<
|
||||
ReturnType<MailSignatureService['imageIdsOf']>,
|
||||
Parameters<MailSignatureService['imageIdsOf']>
|
||||
>;
|
||||
loadImages: jest.Mock<
|
||||
ReturnType<MailSignatureService['loadImages']>,
|
||||
Parameters<MailSignatureService['loadImages']>
|
||||
>;
|
||||
}
|
||||
|
||||
function fakeSignatures(): SignatureMocks {
|
||||
return {
|
||||
imageIdsOf: jest
|
||||
.fn<
|
||||
ReturnType<MailSignatureService['imageIdsOf']>,
|
||||
Parameters<MailSignatureService['imageIdsOf']>
|
||||
>()
|
||||
.mockResolvedValue(new Set()),
|
||||
loadImages: jest
|
||||
.fn<
|
||||
ReturnType<MailSignatureService['loadImages']>,
|
||||
Parameters<MailSignatureService['loadImages']>
|
||||
>()
|
||||
.mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 내 서명 그림 · 남의 서명 그림 (서명 그림 id 는 UUID) */
|
||||
const MY_IMAGE_ID = '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f';
|
||||
const OTHER_IMAGE_ID = '5e6f0b9a-4f9e-4c2d-8d3a-9f4e1a2b3c4d';
|
||||
const imageSrc = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
async function codeOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<string | undefined> {
|
||||
@@ -130,15 +175,45 @@ const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const draftDto = (overrides: Partial<DraftBodyDto> = {}): DraftBodyDto => ({
|
||||
to: [],
|
||||
subject: '쓰는 중',
|
||||
body: '본문',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const draftSource = (overrides: Partial<DraftSource> = {}): DraftSource => ({
|
||||
id: 'draft-1',
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '쓰는 중',
|
||||
textBody: '본문',
|
||||
rawHtmlBody: null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('MailService', () => {
|
||||
let provider: ProviderMocks;
|
||||
let signatures: SignatureMocks;
|
||||
let suggestions: { invalidate: jest.Mock<void, [string]> };
|
||||
let service: MailService;
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = fakeProvider();
|
||||
service = new MailService(provider, settings);
|
||||
signatures = fakeSignatures();
|
||||
suggestions = { invalidate: jest.fn<void, [string]>() };
|
||||
service = new MailService(
|
||||
provider,
|
||||
settings,
|
||||
signatures as unknown as MailSignatureService,
|
||||
suggestions as unknown as RecipientSuggestionService,
|
||||
);
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -294,18 +369,21 @@ describe('MailService', () => {
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-big',
|
||||
attachmentIndex: 1,
|
||||
cid: 'big@x',
|
||||
type: 'image/png',
|
||||
size: 5 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-unused',
|
||||
attachmentIndex: 2,
|
||||
cid: 'unused@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -330,9 +408,13 @@ describe('MailService', () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<p>본문</p><img src="cid:logo@x">',
|
||||
attachments: [
|
||||
{ id: 'blob-logo', name: 'logo.png', type: 'image/png', size: 3 },
|
||||
],
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -344,14 +426,130 @@ describe('MailService', () => {
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
|
||||
// 넣지 못한 그림은 첨부 목록에 남는다 (isInline false)
|
||||
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
|
||||
htmlBody: '<p>본문</p>',
|
||||
attachments: [
|
||||
{
|
||||
id: 'blob-logo',
|
||||
name: 'logo.png',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
isInline: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('첨부의 isInline — 본문에 그림으로 넣은 부분만', () => {
|
||||
const logoPart = {
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 1,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
};
|
||||
const attachments = [
|
||||
{
|
||||
id: 'blob-xlsx',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 2048,
|
||||
},
|
||||
{
|
||||
id: 'blob-logo',
|
||||
name: 'signature-1.png',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
];
|
||||
const inlineOf = (detail: { attachments: { isInline: boolean }[] }) =>
|
||||
detail.attachments.map((attachment) => attachment.isInline);
|
||||
|
||||
it('본문에 넣은 그림은 isInline true, 그 밖의 첨부는 false — 응답에는 정화 전 원본·내부 값이 없다', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<p>서명</p><img src="cid:logo@x">',
|
||||
attachments,
|
||||
inlineParts: [logoPart],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(detail.attachments).toEqual([
|
||||
{ ...attachments[0], isInline: false },
|
||||
{ ...attachments[1], isInline: true },
|
||||
]);
|
||||
expect(detail).not.toHaveProperty('inlineParts');
|
||||
});
|
||||
|
||||
it('넣지 못한 그림은 false — 본문이 가리키지 않음 · 너무 큼 · 버린 영역(svg) 안', async () => {
|
||||
for (const [rawHtmlBody, size] of [
|
||||
['<p>그림을 가리키지 않는 본문</p>', 3],
|
||||
['<img src="cid:logo@x">', 5 * 1024 * 1024],
|
||||
['<svg><img src="cid:logo@x"></svg><p>본문</p>', 3],
|
||||
] as const) {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody,
|
||||
attachments,
|
||||
inlineParts: [{ ...logoPart, size }],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(
|
||||
Buffer.from([1, 2, 3]),
|
||||
);
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('HTML 이 없거나(텍스트 메일) 정화 한도를 넘어 비웠으면 모두 false', async () => {
|
||||
for (const rawHtmlBody of [null, TOO_DEEP_HTML]) {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({ rawHtmlBody, attachments, inlineParts: [logoPart] }),
|
||||
);
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 Content-ID 가 둘이면 실제로 넣은 첫 부분만 isInline', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<img src="cid:logo@x">',
|
||||
attachments: [
|
||||
attachments[1],
|
||||
{ ...attachments[1], id: 'blob-copy' },
|
||||
],
|
||||
inlineParts: [
|
||||
{ ...logoPart, attachmentIndex: 0 },
|
||||
{ ...logoPart, attachmentId: 'blob-copy', attachmentIndex: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('정화 한도를 넘는 HTML(깊은 중첩·이어 붙여 너무 긴 본문)은 정화하지 않고 텍스트 본문만 내려준다 — 내장 이미지도 읽지 않는다', async () => {
|
||||
const logo = {
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -1000,6 +1198,473 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "서식 본문"의 그림 예외 — 보낼 때 내 서명 그림을 메일 안에 싣는다 */
|
||||
describe('서명 그림 (발송)', () => {
|
||||
const png = Buffer.from('89504e470d0a1a0a', 'hex');
|
||||
const cid = `sig-${MY_IMAGE_ID}@arasystem.test`;
|
||||
|
||||
it('내 서명 그림은 cid 로 바꾸고 그림을 함께 실어 보낸다 (width·alt 는 그대로)', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
signatures.loadImages.mockResolvedValue([
|
||||
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
|
||||
]);
|
||||
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml: `<p>김아라 드림</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="아라시스템">`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
|
||||
MY_IMAGE_ID,
|
||||
]);
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.htmlBody).toBe(
|
||||
`<p>김아라 드림</p><img src="cid:${cid}" width="144" alt="아라시스템" />`,
|
||||
);
|
||||
expect(outgoing.inlineImages).toEqual([
|
||||
{ cid, type: 'image/png', name: 'signature-1.png', data: png },
|
||||
]);
|
||||
// 텍스트 대체 본문에는 그림이 없다
|
||||
expect(outgoing.body).toBe('본문');
|
||||
});
|
||||
|
||||
it('같은 그림은 늘 같은 cid 다 — 같은 키의 재시도가 같은 지문이 된다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
signatures.loadImages.mockResolvedValue([
|
||||
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
|
||||
]);
|
||||
const dto = sendDto({
|
||||
bodyHtml: `<p>서명</p><img src="${imageSrc(MY_IMAGE_ID)}">`,
|
||||
});
|
||||
|
||||
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
|
||||
await service.sendMessage(
|
||||
user,
|
||||
dto,
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
);
|
||||
|
||||
const [[, first], [, second]] = provider.sendMessage.mock.calls;
|
||||
expect(first.htmlBody).toBe(second.htmlBody);
|
||||
expect(first.fingerprint).toBe(
|
||||
payloadFingerprint(first, IDEMPOTENCY_KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('남의 그림은 정화가 지우고, 파일을 읽지 못한 내 그림은 태그째 뺀다 — 그림이 없으면 싣지 않는다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
// 그사이 지운 그림 — 서명 서비스가 빼고 돌려준다
|
||||
signatures.loadImages.mockResolvedValue([]);
|
||||
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml: `<p>앞<img src="${imageSrc(OTHER_IMAGE_ID)}">가운데<img src="${imageSrc(MY_IMAGE_ID)}">뒤</p>`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
|
||||
MY_IMAGE_ID,
|
||||
]);
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.htmlBody).toBe('<p>앞가운데뒤</p>');
|
||||
expect(outgoing).not.toHaveProperty('inlineImages');
|
||||
});
|
||||
|
||||
it(`본문의 서명 그림도 서로 다른 내 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (보내지도 저장하지도 않는다)`, async () => {
|
||||
const ids = [
|
||||
MY_IMAGE_ID,
|
||||
'1b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
'2b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
'3b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
];
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set(ids));
|
||||
const img = (id: string) => `<img src="${imageSrc(id)}" width="96">`;
|
||||
const tooMany = `<p>서명</p>${ids.map(img).join('')}`;
|
||||
|
||||
for (const run of [
|
||||
() => service.sendMessage(user, sendDto({ bodyHtml: tooMany })),
|
||||
() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ bodyHtml: tooMany }),
|
||||
IDEMPOTENCY_KEY,
|
||||
),
|
||||
() => service.createDraft(user, draftDto({ bodyHtml: tooMany })),
|
||||
() =>
|
||||
service.replaceDraft(
|
||||
user,
|
||||
'draft-1',
|
||||
draftDto({ bodyHtml: tooMany }),
|
||||
),
|
||||
]) {
|
||||
expect(await failureOf(run)).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'too-many-signature-images',
|
||||
});
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.saveDraft).not.toHaveBeenCalled();
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 — 서로 다른 그림은 세 장이다
|
||||
await expect(
|
||||
service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
bodyHtml: [ids[0], ids[1], ids[2], ids[0]].map(img).join(''),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ id: 'draft-2' });
|
||||
});
|
||||
|
||||
it('서명 그림 주소가 없으면 그림 목록을 읽지 않는다 (DB 를 보지 않는다)', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml:
|
||||
'<p>그림 없는 메일<img src="https://evil.example/p.gif"></p>',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage.mock.calls[0][1].htmlBody).toBe(
|
||||
'<p>그림 없는 메일</p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "임시 저장" — 발송과 같은 준비(정화·한도), 받는 사람 0명도 된다 */
|
||||
describe('임시 저장', () => {
|
||||
it('받는 사람이 없어도 저장한다 — 보내는 정화를 거친 서식 본문을 넘기고, 중복 방지 Message-ID·지문은 넣지 않는다', async () => {
|
||||
await expect(
|
||||
service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
subject: '줄\r\n바꿈',
|
||||
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
|
||||
attachments: [{ id: 'blob-1', name: '체크리스트.xlsx' }],
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: 'draft-2',
|
||||
attachments: [],
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(provider.saveDraft).toHaveBeenCalledTimes(1);
|
||||
const [who, content, replaceId] = provider.saveDraft.mock.calls[0];
|
||||
expect(who).toBe(user.email);
|
||||
expect(replaceId).toBeUndefined();
|
||||
// toEqual 은 정의된 다른 속성(messageId·fingerprint·inlineImages)이 있으면 실패한다
|
||||
expect(content).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '줄 바꿈',
|
||||
body: '본문',
|
||||
htmlBody: '<p>쓰는 중</p>',
|
||||
attachments: [
|
||||
{
|
||||
id: 'blob-1',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
},
|
||||
],
|
||||
inReplyTo: undefined,
|
||||
});
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바꿔 저장은 옛 id 를 함께 넘기고, 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)을 그대로 돌려준다', async () => {
|
||||
await service.replaceDraft(user, 'draft-1', draftDto());
|
||||
expect(provider.saveDraft.mock.calls[0][2]).toBe('draft-1');
|
||||
|
||||
for (const code of ['RES_001', 'MAIL_008'] as const) {
|
||||
provider.saveDraft.mockRejectedValueOnce(new BusinessException(code));
|
||||
expect(
|
||||
await codeOf(() => service.replaceDraft(user, 'draft-1', draftDto())),
|
||||
).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('발송과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · 첨부 개수 MAIL_006 · 첨부 형식 FILE_001 (저장하지 않는다)', async () => {
|
||||
const recipients = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: null,
|
||||
email: `r${index}@arasystem.test`,
|
||||
}));
|
||||
const cases: [Partial<DraftBodyDto>, string, string][] = [
|
||||
[
|
||||
{ body: 'x'.repeat(MAX_BODY_LENGTH + 1) },
|
||||
'MAIL_007',
|
||||
'body-too-long',
|
||||
],
|
||||
[
|
||||
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
|
||||
'MAIL_007',
|
||||
'body-html-too-long',
|
||||
],
|
||||
[{ bodyHtml: TOO_DEEP_HTML }, 'MAIL_007', 'body-html-too-complex'],
|
||||
[{ bodyHtml: '&'.repeat(400_001) }, 'MAIL_007', 'body-html-too-large'],
|
||||
[
|
||||
{ to: recipients(60), cc: recipients(41) },
|
||||
'MAIL_002',
|
||||
'too-many-recipients',
|
||||
],
|
||||
[
|
||||
{
|
||||
attachments: Array.from({ length: 21 }, (_, index) => ({
|
||||
id: `blob-${index}`,
|
||||
name: `보고서${index}.pdf`,
|
||||
})),
|
||||
},
|
||||
'MAIL_006',
|
||||
'too-many-attachments',
|
||||
],
|
||||
[
|
||||
{ attachments: [{ id: 'blob-1', name: '설치.exe' }] },
|
||||
'FILE_001',
|
||||
'attachment-type-not-allowed',
|
||||
],
|
||||
];
|
||||
|
||||
for (const [overrides, code, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() => service.createDraft(user, draftDto(overrides))),
|
||||
).toEqual({ code, reason });
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.replaceDraft(user, 'draft-1', draftDto(overrides)),
|
||||
),
|
||||
).toEqual({ code, reason });
|
||||
}
|
||||
expect(provider.saveDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('서명 그림은 내 그림만 그림 주소 그대로 남긴다 — 저장본에는 그림을 싣지 않는다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
|
||||
await service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
bodyHtml: `<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt=""><img src="${imageSrc(OTHER_IMAGE_ID)}" width="144">`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.imageIdsOf).toHaveBeenCalledWith(user.email);
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
expect(provider.saveDraft.mock.calls[0][1].htmlBody).toBe(
|
||||
`<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="" />`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('이어 쓰기', () => {
|
||||
it('엔진의 초안을 계약 모양으로 — 서식 본문은 보내는 정화를 거친다 (다른 메일 프로그램이 만든 초안도)', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({
|
||||
rawHtmlBody: `<html><body><h1 style="color: red">제목</h1><p>본문<img src="https://tracker.example/p.gif"></p><img src="${imageSrc(MY_IMAGE_ID)}" width="96"><img src="${imageSrc(OTHER_IMAGE_ID)}"></body></html>`,
|
||||
inReplyTo: 'eaaaaab',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.getDraft(user, 'draft-1')).resolves.toEqual({
|
||||
id: 'draft-1',
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '쓰는 중',
|
||||
body: '본문',
|
||||
// 모르는 태그(h1)는 태그만 지우고 글자는 남긴다 — 외부 이미지·남의 그림은 태그째
|
||||
bodyHtml: `제목<p>본문</p><img src="${imageSrc(MY_IMAGE_ID)}" width="96" />`,
|
||||
attachments: [],
|
||||
inReplyTo: 'eaaaaab',
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
});
|
||||
expect(provider.getDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
});
|
||||
|
||||
it('텍스트 본문이 없으면 빈 문자열, 서식 본문이 없거나 정화해서 비면 null', async () => {
|
||||
for (const rawHtmlBody of [null, '', '<script>x()</script>']) {
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({ textBody: null, rawHtmlBody }),
|
||||
);
|
||||
|
||||
const draft = await service.getDraft(user, 'draft-1');
|
||||
expect(draft.body).toBe('');
|
||||
expect(draft.bodyHtml).toBeNull();
|
||||
}
|
||||
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('정화 한도를 넘는 서식 본문은 정화하지 않고 null — 텍스트 본문으로 이어 쓴다 (본문은 로그에 없다)', async () => {
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({ textBody: '비밀 본문', rawHtmlBody: TOO_DEEP_HTML }),
|
||||
);
|
||||
|
||||
const draft = await service.getDraft(user, 'draft-1');
|
||||
|
||||
expect(draft.bodyHtml).toBeNull();
|
||||
expect(draft.body).toBe('비밀 본문');
|
||||
const warned = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(warned).toContain('임시 저장본 HTML 이 정화 한도를 넘어');
|
||||
expect(warned).not.toContain('비밀');
|
||||
});
|
||||
|
||||
it('초안이 아니면 엔진의 RES_001 을 그대로 돌려준다', async () => {
|
||||
provider.getDraft.mockRejectedValue(new BusinessException('RES_001'));
|
||||
|
||||
expect(await codeOf(() => service.getDraft(user, 'm1'))).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */
|
||||
describe('보낸 뒤', () => {
|
||||
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
|
||||
await expect(
|
||||
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
|
||||
// 지우기는 보낸 뒤다
|
||||
expect(provider.sendMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
provider.deleteDraft.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('draftId 가 없으면 지우지 않는다 — 추천은 다시 모은다', async () => {
|
||||
await service.sendMessage(user, sendDto());
|
||||
|
||||
expect(provider.deleteDraft).not.toHaveBeenCalled();
|
||||
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
|
||||
it('같은 키 재시도로 처음 결과를 돌려줄 때도 초안을 지운다', async () => {
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
|
||||
provider.sendMessage.mockClear();
|
||||
provider.findSentMessage.mockResolvedValue({
|
||||
id: 'sent-1',
|
||||
fingerprint: fingerprint ?? null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
IDEMPOTENCY_KEY,
|
||||
),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
});
|
||||
|
||||
it('draftId 는 지문에 넣지 않는다 — 초안 id 만 다른 재시도는 같은 내용이다', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
IDEMPOTENCY_KEY,
|
||||
);
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
|
||||
const [[, withDraft], [, withoutDraft]] = provider.sendMessage.mock.calls;
|
||||
expect(withDraft).not.toHaveProperty('draftId');
|
||||
expect(withDraft.fingerprint).toBe(withoutDraft.fingerprint);
|
||||
});
|
||||
|
||||
it('초안을 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (주소·제목 없이)', async () => {
|
||||
provider.deleteDraft.mockRejectedValue(
|
||||
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ subject: '비밀 제목', draftId: 'draft-1' }),
|
||||
),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
const warned = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(warned).toContain('[MAIL_001]');
|
||||
expect(warned).toContain('보낸 메일의 임시 저장본을 지우지 못했습니다');
|
||||
expect(warned).toContain('user=a***@arasystem.test');
|
||||
expect(warned).not.toMatch(/비밀|doyun|ara\.kim/);
|
||||
});
|
||||
|
||||
it('보내지 못하면 초안을 지우지 않고 추천도 그대로다', async () => {
|
||||
provider.sendMessage.mockRejectedValue(new BusinessException('MAIL_004'));
|
||||
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
|
||||
),
|
||||
).toBe('MAIL_004');
|
||||
expect(provider.deleteDraft).not.toHaveBeenCalled();
|
||||
expect(suggestions.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`초안 지우기는 ${DRAFT_CLEANUP_WAIT_MS}ms 까지만 기다린다 — 넘으면 먼저 답하고, 뒤에서 끝난 결과는 로그로만 남긴다`, async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
try {
|
||||
let finish!: () => void;
|
||||
provider.deleteDraft.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const sending = service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
);
|
||||
// 보내고 지우기를 시작할 때까지
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
jest.advanceTimersByTime(DRAFT_CLEANUP_WAIT_MS);
|
||||
|
||||
await expect(sending).resolves.toEqual({ id: 'sent-1' });
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'보낸 메일의 임시 저장본 지우기가 5000ms 안에 끝나지 않아 먼저 답합니다',
|
||||
),
|
||||
);
|
||||
|
||||
// 뒤에서 끝났다 — 결과는 로그로만
|
||||
jest.advanceTimersByTime(1_000);
|
||||
finish();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('보낸 메일의 임시 저장본을 늦게 지웠습니다'),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('초안 지우기가 제때 끝나면 기다렸다가 답한다 — 경고 없음', async () => {
|
||||
await service.sendMessage(user, sendDto({ draftId: 'draft-1' }));
|
||||
|
||||
expect(provider.deleteDraft).toHaveBeenCalledTimes(1);
|
||||
const warned = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(warned.join('\n')).not.toContain('먼저 답합니다');
|
||||
});
|
||||
});
|
||||
|
||||
it('업로드는 정리한 이름과 확장자로 정한 형식을 엔진에 넘긴다', async () => {
|
||||
provider.uploadAttachment.mockResolvedValue({
|
||||
id: 'blob-1',
|
||||
|
||||
@@ -6,15 +6,24 @@ import {
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { settledWithin } from '@/shared/utils/settle';
|
||||
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 {
|
||||
ComposeContentDto,
|
||||
DraftBodyDto,
|
||||
RecipientDto,
|
||||
SendMessageDto,
|
||||
} from './dto/send-message.dto';
|
||||
import type { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import {
|
||||
INLINE_IMAGES_TOTAL_MAX_BYTES,
|
||||
@@ -27,6 +36,11 @@ import {
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
import {
|
||||
SIGNATURE_IMAGE_PATH_PREFIX,
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
} from './html/signature-images';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
@@ -37,17 +51,24 @@ import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
Draft,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailQuota,
|
||||
MessageAttachment,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
OutgoingAttachment,
|
||||
OutgoingInlineImage,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import {
|
||||
idempotentMessageId,
|
||||
parseIdempotencyKey,
|
||||
@@ -61,6 +82,12 @@ import {
|
||||
*/
|
||||
const UNCERTAIN_SEND_HOLD_MS = 30_000;
|
||||
|
||||
/**
|
||||
* 보낸 뒤 이어 쓴 임시 저장본(draftId)을 지우는 것을 기다리는 최대 시간 — 보통은 끝날 때까지 기다려
|
||||
* 보낸 직후의 임시보관함 목록에서 그 초안이 사라져 있게 한다. 넘으면 먼저 답하고 지우기는 뒤에서 계속한다 (결과는 로그로만).
|
||||
*/
|
||||
export const DRAFT_CLEANUP_WAIT_MS = 5_000;
|
||||
|
||||
/** 헤더에 들어가는 값(제목·표시 이름)의 줄바꿈을 공백으로 — 헤더 분리 시도를 막는다 */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, ' ').trim();
|
||||
@@ -71,28 +98,42 @@ function toAddress(recipient: RecipientDto): MailAddress {
|
||||
return { name: name || null, email: recipient.email.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 본문의 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
|
||||
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화 → 정화 뒤 바이트.
|
||||
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
|
||||
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
|
||||
|
||||
/** 준비를 어디에 쓰나 — 보낼 때만 서명 그림을 메일 안에 싣는다 (임시 저장본은 그림 주소 그대로) */
|
||||
type ComposePurpose = 'send' | 'draft';
|
||||
|
||||
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
|
||||
|
||||
/** 본문에 그림으로 넣은 첨부가 없다 (HTML 없이 텍스트 본문을 보여 줄 때) */
|
||||
const NO_INLINE_ATTACHMENTS: ReadonlySet<number> = new Set();
|
||||
|
||||
/** 본문에 넣으려고 읽은 내장 이미지 — 정규화한 Content-ID 로 찾는다 */
|
||||
interface LoadedInlineImages {
|
||||
/** Content-ID → data: URI (정화가 cid: 를 이것으로 바꾼다) */
|
||||
readonly dataUris: Map<string, string>;
|
||||
/** Content-ID → 그 그림을 가져온 첨부의 위치 (attachments 의 몇 번째) */
|
||||
readonly attachmentIndexes: Map<string, number>;
|
||||
}
|
||||
|
||||
/*
|
||||
* 본문 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
|
||||
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화(서명 그림 포함) → (보낼 때) 그림을 cid 로 → 정화 뒤 바이트.
|
||||
* 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트가 보내기 전에 세는 방법과 같다
|
||||
* (class-validator 의 MaxLength 는 서로게이트 쌍·이모지 표시 선택자를 다르게 세서 클라이언트와 어긋났다).
|
||||
* @returns 정화한 서식 본문 — 서식 본문이 없거나 정화해서 비면 빈 문자열(텍스트 메일)
|
||||
*/
|
||||
function prepareBody(body: string, bodyHtml: string | undefined): string {
|
||||
|
||||
function assertBodyLength(body: string): void {
|
||||
if (body.length > MAX_BODY_LENGTH) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
return bodyHtml ? sanitizeBodyHtml(bodyHtml) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서식 본문 정화 — 앞뒤로 한도를 본다 (넘으면 MAIL_007).
|
||||
* - 정화 전: 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
|
||||
* - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts)
|
||||
*/
|
||||
function sanitizeBodyHtml(bodyHtml: string): string {
|
||||
/** 정화 전 — 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts) */
|
||||
function assertHtmlBeforeSanitize(bodyHtml: string): void {
|
||||
if (bodyHtml.length > MAX_BODY_HTML_LENGTH) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-html-too-long', length: bodyHtml.length },
|
||||
@@ -109,20 +150,28 @@ function sanitizeBodyHtml(bodyHtml: string): string {
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const html = sanitizeOutgoingHtml(bodyHtml);
|
||||
/** 정화 뒤 — 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts) */
|
||||
function assertHtmlBytes(html: string): void {
|
||||
const bytes = Buffer.byteLength(html, 'utf8');
|
||||
if (bytes > MAX_BODY_VALUE_BYTES) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-html-too-large', bytes },
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
|
||||
function signatureImageCid(imageId: string, sender: string): string {
|
||||
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
|
||||
return `sig-${imageId}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식, 서명 그림)은 여기서 지킨다.
|
||||
* 발송과 임시 저장은 같은 준비(prepareContent)를 거친다 — 같은 필드·같은 한도 (계약 2절 "임시 저장").
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
@@ -137,6 +186,8 @@ export class MailService {
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
private readonly signatures: MailSignatureService,
|
||||
private readonly suggestions: RecipientSuggestionService,
|
||||
) {}
|
||||
|
||||
async listMailboxes(
|
||||
@@ -195,6 +246,7 @@ export class MailService {
|
||||
* 그때 isHtmlBodyOmitted 를 켜고, 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
|
||||
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
|
||||
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
|
||||
* 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다).
|
||||
*/
|
||||
async getMessage(
|
||||
user: AuthenticatedUser,
|
||||
@@ -205,12 +257,21 @@ export class MailService {
|
||||
isRawHtmlBodyTruncated,
|
||||
isTextBodyTruncated,
|
||||
inlineParts,
|
||||
attachments,
|
||||
...detail
|
||||
} = await this.provider.getMessage(user.email, id);
|
||||
|
||||
/** 첨부 — 본문에 그림으로 넣은 위치만 isInline */
|
||||
const markInline = (inlined: ReadonlySet<number>): MessageAttachment[] =>
|
||||
attachments.map((attachment, index) => ({
|
||||
...attachment,
|
||||
isInline: inlined.has(index),
|
||||
}));
|
||||
|
||||
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
|
||||
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
|
||||
...detail,
|
||||
attachments: markInline(NO_INLINE_ATTACHMENTS),
|
||||
htmlBody: null,
|
||||
hasBlockedImages: false,
|
||||
isHtmlBodyOmitted,
|
||||
@@ -234,10 +295,17 @@ export class MailService {
|
||||
rawHtmlBody,
|
||||
inlineParts,
|
||||
);
|
||||
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
|
||||
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris);
|
||||
const inlined = new Set(
|
||||
[...sanitized.embeddedContentIds].flatMap((contentId) => {
|
||||
const index = inlineImages.attachmentIndexes.get(contentId);
|
||||
return index === undefined ? [] : [index];
|
||||
}),
|
||||
);
|
||||
const showsHtml = sanitized.html.trim() !== '';
|
||||
return {
|
||||
...detail,
|
||||
attachments: markInline(inlined),
|
||||
htmlBody: sanitized.html,
|
||||
hasBlockedImages: sanitized.hasBlockedImages,
|
||||
isHtmlBodyOmitted: false,
|
||||
@@ -277,6 +345,9 @@ export class MailService {
|
||||
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
|
||||
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
|
||||
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
|
||||
*
|
||||
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
|
||||
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
|
||||
*/
|
||||
async sendMessage(
|
||||
user: AuthenticatedUser,
|
||||
@@ -290,9 +361,52 @@ export class MailService {
|
||||
});
|
||||
}
|
||||
|
||||
const message = this.prepareOutgoing(dto);
|
||||
if (parsed.key === null) return this.deliver(user, message);
|
||||
return this.deliverOnce(user, message, parsed.key);
|
||||
const message = await this.prepareOutgoing(user, dto);
|
||||
const result =
|
||||
parsed.key === null
|
||||
? await this.deliver(user, message)
|
||||
: await this.deliverOnce(user, message, parsed.key);
|
||||
await this.afterSent(user, dto.draftId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
|
||||
async createDraft(
|
||||
user: AuthenticatedUser,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content);
|
||||
}
|
||||
|
||||
/** 임시 저장 — 바꿔 저장. 새 id 로 돌려준다 (초안이 아니면 RES_001, 다른 곳에서 먼저 바뀌었으면 MAIL_008) */
|
||||
async replaceDraft(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이어 쓸 임시 저장본 — 서식 본문은 보내는 정화(서명 그림 포함)를 거쳐 내보낸다.
|
||||
* 다른 메일 프로그램이 만든 초안도 같다 — 정화 한도(길이·깊은 중첩)를 넘는 HTML 이면 bodyHtml 은 null (텍스트 본문으로 이어 쓴다).
|
||||
*/
|
||||
async getDraft(user: AuthenticatedUser, id: string): Promise<Draft> {
|
||||
const source = await this.provider.getDraft(user.email, id);
|
||||
return {
|
||||
id: source.id,
|
||||
to: source.to,
|
||||
cc: source.cc,
|
||||
bcc: source.bcc,
|
||||
subject: source.subject,
|
||||
body: source.textBody ?? '',
|
||||
bodyHtml: await this.draftHtml(user, source),
|
||||
attachments: source.attachments,
|
||||
inReplyTo: source.inReplyTo,
|
||||
updatedAt: source.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
|
||||
@@ -358,8 +472,34 @@ export class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 본문 한도(MAIL_007)·서식 본문 정화 */
|
||||
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
|
||||
/** 보낼 메일 — 준비에 서명 그림(cid)을 더한다 */
|
||||
private async prepareOutgoing(
|
||||
user: AuthenticatedUser,
|
||||
dto: SendMessageDto,
|
||||
): Promise<OutgoingMessage> {
|
||||
const { content, inlineImages } = await this.prepareContent(
|
||||
user,
|
||||
dto,
|
||||
'send',
|
||||
);
|
||||
return {
|
||||
...content,
|
||||
...(inlineImages.length > 0 ? { inlineImages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진과 무관한 발송·임시 저장 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈,
|
||||
* 본문 한도(MAIL_007)·서식 본문 정화(서명 그림 포함)
|
||||
*/
|
||||
private async prepareContent(
|
||||
user: AuthenticatedUser,
|
||||
dto: ComposeInput,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{
|
||||
content: DraftContent;
|
||||
inlineImages: OutgoingInlineImage[];
|
||||
}> {
|
||||
const to = dto.to.map(toAddress);
|
||||
const cc = (dto.cc ?? []).map(toAddress);
|
||||
const bcc = (dto.bcc ?? []).map(toAddress);
|
||||
@@ -392,20 +532,168 @@ export class MailService {
|
||||
});
|
||||
|
||||
// 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
|
||||
const htmlBody = prepareBody(dto.body, dto.bodyHtml);
|
||||
const { htmlBody, inlineImages } = await this.prepareBody(
|
||||
user,
|
||||
dto.body,
|
||||
dto.bodyHtml,
|
||||
purpose,
|
||||
);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
...(htmlBody ? { htmlBody } : {}),
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
content: {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
...(htmlBody ? { htmlBody } : {}),
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
},
|
||||
inlineImages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 한도와 서식 본문 정화 — 순서는 위 "본문 한도" 주석 그대로. 정화한 뒤 서명 그림이 서로 다른 3장을 넘으면 VAL_001.
|
||||
* 서명 그림은 보내는 사람 자신의 그림만 남는다 — 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다.
|
||||
* @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일)과 메일에 함께 실을 그림(보낼 때만)
|
||||
*/
|
||||
private async prepareBody(
|
||||
user: AuthenticatedUser,
|
||||
body: string,
|
||||
bodyHtml: string | undefined,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
|
||||
assertBodyLength(body);
|
||||
if (!bodyHtml) return { htmlBody: '', inlineImages: [] };
|
||||
|
||||
assertHtmlBeforeSanitize(bodyHtml);
|
||||
const sanitized = sanitizeOutgoingHtml(bodyHtml, {
|
||||
signatureImageIds: await this.signatureImageIdsFor(user, bodyHtml),
|
||||
});
|
||||
// 서명 그림은 서명 저장과 같은 한도 — 서로 다른 내 그림 3장까지 (정화가 남의 그림은 이미 지웠다). 보내기 전이라 메일은 나가지 않는다
|
||||
const imageCount = findSignatureImageIds(sanitized).length;
|
||||
if (imageCount > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'too-many-signature-images', count: imageCount },
|
||||
});
|
||||
}
|
||||
const prepared =
|
||||
purpose === 'send'
|
||||
? await this.embedSignatureImages(user, sanitized)
|
||||
: { htmlBody: sanitized, inlineImages: [] };
|
||||
assertHtmlBytes(prepared.htmlBody);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
/** 서명 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다 (없으면 DB 를 보지 않는다) */
|
||||
private async signatureImageIdsFor(
|
||||
user: AuthenticatedUser,
|
||||
html: string,
|
||||
): Promise<ReadonlySet<string>> {
|
||||
return html.toLowerCase().includes(SIGNATURE_IMAGE_PATH_PREFIX)
|
||||
? this.signatures.imageIdsOf(user.email)
|
||||
: NO_SIGNATURE_IMAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낼 때 — 서명 그림 주소를 `cid:` 로 바꾸고 그림을 메일 안에 함께 싣게 한다 (계약 2절 "서식 본문"의 그림 예외).
|
||||
* 실을 수 없는 그림(그사이 지운 그림 등)은 태그째 지운다.
|
||||
*/
|
||||
private async embedSignatureImages(
|
||||
user: AuthenticatedUser,
|
||||
html: string,
|
||||
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
|
||||
const ids = findSignatureImageIds(html);
|
||||
if (ids.length === 0) return { htmlBody: html, inlineImages: [] };
|
||||
|
||||
const images = await this.signatures.loadImages(user.email, ids);
|
||||
const cids = new Map(
|
||||
images.map((image) => [
|
||||
image.id,
|
||||
signatureImageCid(image.id, user.email),
|
||||
]),
|
||||
);
|
||||
const inlineImages = images.map(
|
||||
(image, index): OutgoingInlineImage => ({
|
||||
cid: cids.get(image.id) ?? '',
|
||||
type: image.type,
|
||||
name: `signature-${index + 1}.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}`,
|
||||
data: image.data,
|
||||
}),
|
||||
);
|
||||
const htmlBody = rewriteSignatureImages(html, (id) => {
|
||||
const cid = cids.get(id);
|
||||
return cid ? `cid:${cid}` : null;
|
||||
}).trim();
|
||||
return { htmlBody, inlineImages };
|
||||
}
|
||||
|
||||
/** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */
|
||||
private async draftHtml(
|
||||
user: AuthenticatedUser,
|
||||
source: DraftSource,
|
||||
): Promise<string | null> {
|
||||
const raw = source.rawHtmlBody;
|
||||
if (!raw) return null;
|
||||
|
||||
const budget = checkHtmlParseBudget(raw);
|
||||
if (!budget.ok) {
|
||||
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
|
||||
this.logger.warn(
|
||||
`${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const html = sanitizeOutgoingHtml(raw, {
|
||||
signatureImageIds: await this.signatureImageIdsFor(user, raw),
|
||||
});
|
||||
return html === '' ? null : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낸 뒤 — 받는 사람 추천을 다시 모으게 하고, 이어 쓴 임시 저장본을 지운다.
|
||||
* 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (초안이 하나 남을 뿐이다).
|
||||
* 지우기는 DRAFT_CLEANUP_WAIT_MS 까지만 기다린다 — 엔진이 늦으면 먼저 답하고 뒤에서 계속한다 (발송 응답을 붙잡지 않게).
|
||||
*/
|
||||
private async afterSent(
|
||||
user: AuthenticatedUser,
|
||||
draftId: string | undefined,
|
||||
): Promise<void> {
|
||||
this.suggestions.invalidate(user.email);
|
||||
if (!draftId) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const cleanup = this.deleteSentDraft(user, draftId, startedAt);
|
||||
if (!(await settledWithin(cleanup, DRAFT_CLEANUP_WAIT_MS))) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본 지우기가 ${DRAFT_CLEANUP_WAIT_MS}ms 안에 끝나지 않아 먼저 답합니다 — 뒤에서 계속합니다: user=${maskEmail(user.email)}, id=${draftId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 보낸 메일의 임시 저장본을 지운다 — 던지지 않는다 (발송은 이미 성공이다). 기다림을 넘겨 끝났으면 그 결과를 로그로 */
|
||||
private async deleteSentDraft(
|
||||
user: AuthenticatedUser,
|
||||
draftId: string,
|
||||
startedAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.provider.deleteDraft(user.email, draftId);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs > DRAFT_CLEANUP_WAIT_MS) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 보낸 메일의 임시 저장본을 늦게 지웠습니다 (${elapsedMs}ms): user=${maskEmail(user.email)}, id=${draftId}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본을 지우지 못했습니다: user=${maskEmail(user.email)}, id=${draftId}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
@@ -458,8 +746,11 @@ export class MailService {
|
||||
user: string,
|
||||
html: string,
|
||||
parts: readonly InlinePart[],
|
||||
): Promise<Map<string, string>> {
|
||||
const images = new Map<string, string>();
|
||||
): Promise<LoadedInlineImages> {
|
||||
const images: LoadedInlineImages = {
|
||||
dataUris: new Map(),
|
||||
attachmentIndexes: new Map(),
|
||||
};
|
||||
const candidates = selectInlineImages(
|
||||
parts,
|
||||
findReferencedContentIds(html),
|
||||
@@ -496,7 +787,9 @@ export class MailService {
|
||||
if (!dataUri) continue;
|
||||
|
||||
total += data.length;
|
||||
images.set(normalizeContentId(part.cid), dataUri);
|
||||
const contentId = normalizeContentId(part.cid);
|
||||
images.dataUris.set(contentId, dataUri);
|
||||
images.attachmentIndexes.set(contentId, part.attachmentIndex);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,16 @@ export interface MailAttachment {
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** 메일 상세의 첨부 — 본문 안에 그림으로 넣었는지 함께 (업로드 응답·임시 저장본의 첨부는 MailAttachment 그대로) */
|
||||
export interface MessageAttachment extends MailAttachment {
|
||||
/**
|
||||
* 이 부분을 돌려준 htmlBody 안에 그림(data: URI)으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다.
|
||||
* 너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우(isHtmlBodyOmitted)·텍스트 메일은 false.
|
||||
* 전달할 때는 isInline 이어도 모두 붙인다 (원문 그림을 잃지 않게)
|
||||
*/
|
||||
readonly isInline: boolean;
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
@@ -75,12 +85,14 @@ export interface MessageDetail extends MessageSummary {
|
||||
* 보여 줄 본문 = htmlBody, 그것이 null 이거나 공백뿐이면 textBody (웹 클라이언트의 buildMailDocument 와 같은 규칙)
|
||||
*/
|
||||
readonly isBodyTruncated: boolean;
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly attachments: MessageAttachment[];
|
||||
}
|
||||
|
||||
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
|
||||
export interface InlinePart {
|
||||
readonly attachmentId: string;
|
||||
/** 상세의 attachments 에서 몇 번째 부분인지 — 본문에 넣었으면 그 첨부의 isInline 을 켠다 */
|
||||
readonly attachmentIndex: number;
|
||||
/** Content-ID (꺾쇠 괄호 없이) */
|
||||
readonly cid: string;
|
||||
readonly type: string;
|
||||
@@ -99,7 +111,10 @@ export interface MessageDetailSource extends Omit<
|
||||
| 'hasBlockedImages'
|
||||
| 'isHtmlBodyOmitted'
|
||||
| 'isBodyTruncated'
|
||||
| 'attachments'
|
||||
> {
|
||||
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly rawHtmlBody: string | null;
|
||||
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
|
||||
readonly isRawHtmlBodyTruncated: boolean;
|
||||
@@ -140,6 +155,18 @@ export interface OutgoingAttachment {
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 안에 함께 싣는 그림 (서명 그림) — 서식 본문이 `cid:{cid}` 로 가리킨다.
|
||||
* 어댑터는 서식 본문과 함께 multipart/related 로 묶는다 (계약 2절 "서식 본문"의 그림 예외).
|
||||
*/
|
||||
export interface OutgoingInlineImage {
|
||||
/** Content-ID (꺾쇠 괄호 없이) — 같은 그림이면 늘 같은 값이다 (발송 지문이 서식 본문에 든 cid 를 본다) */
|
||||
readonly cid: string;
|
||||
readonly type: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
@@ -153,6 +180,8 @@ export interface OutgoingMessage {
|
||||
*/
|
||||
readonly htmlBody?: string;
|
||||
readonly attachments: OutgoingAttachment[];
|
||||
/** 서식 본문이 cid 로 가리키는 서명 그림 — 있으면 multipart/related 로 함께 싣는다 */
|
||||
readonly inlineImages?: readonly OutgoingInlineImage[];
|
||||
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
|
||||
readonly inReplyTo?: string;
|
||||
/**
|
||||
@@ -164,6 +193,84 @@ export interface OutgoingMessage {
|
||||
readonly fingerprint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/api/mail-signature/images/{id}` 주소 그대로 둔다.
|
||||
* 발송 중복 방지용 Message-ID·지문은 넣지 않는다 (계약 2절 "임시 저장").
|
||||
*/
|
||||
export type DraftContent = Pick<
|
||||
OutgoingMessage,
|
||||
| 'to'
|
||||
| 'cc'
|
||||
| 'bcc'
|
||||
| 'subject'
|
||||
| 'body'
|
||||
| 'htmlBody'
|
||||
| 'attachments'
|
||||
| 'inReplyTo'
|
||||
>;
|
||||
|
||||
/** 저장한 임시 저장본 — 저장할 때마다 id·첨부 id 가 바뀐다 */
|
||||
export interface SavedDraft {
|
||||
readonly id: string;
|
||||
readonly attachments: MailAttachment[];
|
||||
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터가 돌려주는 임시 저장본 원본 — HTML 은 정화 전 값이다(다른 메일 프로그램이 만든 초안일 수 있다).
|
||||
* 서비스가 보내는 정화를 거친 뒤에만 Draft 로 내보낸다.
|
||||
*/
|
||||
export interface DraftSource {
|
||||
readonly id: string;
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
readonly textBody: string | null;
|
||||
readonly rawHtmlBody: string | null;
|
||||
readonly attachments: MailAttachment[];
|
||||
/** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */
|
||||
readonly inReplyTo: string | null;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
|
||||
export interface Draft {
|
||||
readonly id: string;
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
/** 보내는 정화를 거친 서식 본문 — 없거나 비면 null */
|
||||
readonly bodyHtml: string | null;
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly inReplyTo: string | null;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
|
||||
export interface Correspondence {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
/** 그 메일의 받은 시각 (ISO 8601 UTC) */
|
||||
readonly at: string;
|
||||
}
|
||||
|
||||
/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */
|
||||
export interface DirectoryContact {
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** 받는 사람 추천 (계약 2절 "받는 사람 추천") */
|
||||
export interface RecipientSuggestion {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
readonly source: 'directory' | 'recent';
|
||||
}
|
||||
|
||||
/** 같은 Message-ID 로 이미 발송한 메일 */
|
||||
export interface SentMessageRecord {
|
||||
/** 보낸편지함의 메일 id */
|
||||
|
||||
@@ -151,10 +151,12 @@ export class MessageController {
|
||||
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
|
||||
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
|
||||
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
|
||||
'보내는 사람 자신의 서명 그림(<img src="/api/mail-signature/images/{id}">)만 남겨 메일 안에 함께 싣는다(multipart/related, cid:). ' +
|
||||
'draftId(이어 쓴 임시 저장본)가 있으면 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 그 초안을 지운다(지우지 못해도 발송은 성공). ' +
|
||||
`본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` +
|
||||
`bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` +
|
||||
'엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' +
|
||||
'이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
|
||||
'이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
@@ -169,7 +171,7 @@ export class MessageController {
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'MAIL_002',
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식·draftId 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Correspondence } from './mail.types';
|
||||
import {
|
||||
MAX_RECIPIENT_SUGGESTIONS,
|
||||
filterRecipientSuggestions,
|
||||
rankRecipientSuggestions,
|
||||
} from './recipient-ranking';
|
||||
|
||||
const NOW = new Date('2026-10-27T12:00:00.000Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const ago = (days: number) =>
|
||||
new Date(NOW.getTime() - days * DAY).toISOString();
|
||||
const ME = 'ara.kim@arasystem.test';
|
||||
const EXCLUDED = new Set([
|
||||
'admin@arasystem.test',
|
||||
'mail-gateway@arasystem.test',
|
||||
'blocked@arasystem.test',
|
||||
]);
|
||||
|
||||
const sent = (
|
||||
email: string,
|
||||
days: number,
|
||||
name: string | null = null,
|
||||
): Correspondence => ({
|
||||
email,
|
||||
name,
|
||||
at: ago(days),
|
||||
});
|
||||
|
||||
const rank = (
|
||||
correspondence: Correspondence[],
|
||||
directory: { email: string; name: string }[] = [],
|
||||
) =>
|
||||
rankRecipientSuggestions({
|
||||
me: ME,
|
||||
excluded: EXCLUDED,
|
||||
directory,
|
||||
correspondence,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
|
||||
it('사내 → 최근 주고받은 주소 순이고, 두 쪽에 다 있는 주소는 사내로 한 번만 나온다', () => {
|
||||
const result = rank(
|
||||
[sent('partner@vendor.example', 0), sent('doyun.lee@arasystem.test', 0)],
|
||||
[
|
||||
{ email: 'Doyun.Lee@arasystem.test', name: '이도윤' },
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{
|
||||
name: '박서준',
|
||||
email: 'seojun.park@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{ name: null, email: 'partner@vendor.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('묶음 안에서는 횟수와 최근성으로 — 오래된 한 번은 반감기(30일)로 줄어든다', () => {
|
||||
const result = rank([
|
||||
// 60일 전 3번 = 0.75 · 오늘 1번 = 1 · 60일 전 5번 = 1.25
|
||||
...Array.from({ length: 3 }, () => sent('old-three@x.example', 60)),
|
||||
sent('today-once@x.example', 0),
|
||||
...Array.from({ length: 5 }, () => sent('old-five@x.example', 60)),
|
||||
]);
|
||||
|
||||
expect(result.map((item) => item.email)).toEqual([
|
||||
'old-five@x.example',
|
||||
'today-once@x.example',
|
||||
'old-three@x.example',
|
||||
]);
|
||||
});
|
||||
|
||||
it('사내 안에서도 주고받은 사람이 먼저고, 나머지는 이름 순이다', () => {
|
||||
const result = rank(
|
||||
[sent('seojun.park@arasystem.test', 1)],
|
||||
[
|
||||
{ email: 'ga@arasystem.test', name: '가나다' },
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
{ email: 'ha@arasystem.test', name: '하늘' },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.name)).toEqual([
|
||||
'박서준',
|
||||
'가나다',
|
||||
'하늘',
|
||||
]);
|
||||
});
|
||||
|
||||
it('나·게이트웨이·관리자·막은 계정·noreply 류·주소가 아닌 값은 뺀다 (대소문자 무시)', () => {
|
||||
const result = rank(
|
||||
[
|
||||
sent('ARA.KIM@arasystem.test', 0),
|
||||
sent('admin@arasystem.test', 0),
|
||||
sent('mail-gateway@arasystem.test', 0),
|
||||
sent('blocked@arasystem.test', 0),
|
||||
...[
|
||||
'noreply@service.example',
|
||||
'no-reply@service.example',
|
||||
'no_reply@service.example',
|
||||
'NoReply+alerts@service.example',
|
||||
'donotreply@service.example',
|
||||
'do-not-reply@service.example',
|
||||
'mailer-daemon@service.example',
|
||||
].map((email) => sent(email, 0)),
|
||||
sent('not-an-address', 0),
|
||||
sent('@nolocal.example', 0),
|
||||
sent('kept@service.example', 0),
|
||||
],
|
||||
[{ email: 'admin@arasystem.test', name: '관리자' }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: null, email: 'kept@service.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => {
|
||||
const result = rank(
|
||||
[
|
||||
sent('partner@vendor.example', 10, '옛 이름'),
|
||||
sent('partner@vendor.example', 1, '새 이름'),
|
||||
sent('partner@vendor.example', 0, ' '),
|
||||
sent('colleague@arasystem.test', 0, '메일에 적힌 이름'),
|
||||
],
|
||||
[{ email: 'colleague@arasystem.test', name: ' ' }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: '메일에 적힌 이름',
|
||||
email: 'colleague@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{ name: '새 이름', email: 'partner@vendor.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it(`한 사람에게 ${MAX_RECIPIENT_SUGGESTIONS}개까지만 기억한다`, () => {
|
||||
const result = rank(
|
||||
Array.from({ length: MAX_RECIPIENT_SUGGESTIONS + 50 }, (_, i) =>
|
||||
sent(`person${i}@x.example`, i % 30),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(MAX_RECIPIENT_SUGGESTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRecipientSuggestions (q 로 거르기)', () => {
|
||||
const list = [
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory' as const,
|
||||
},
|
||||
{ name: null, email: 'Partner@Vendor.example', source: 'recent' as const },
|
||||
];
|
||||
|
||||
it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => {
|
||||
expect(filterRecipientSuggestions(list, '도윤')).toEqual([list[0]]);
|
||||
expect(filterRecipientSuggestions(list, ' DOYUN ')).toEqual([list[0]]);
|
||||
expect(filterRecipientSuggestions(list, 'vendor')).toEqual([list[1]]);
|
||||
expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]);
|
||||
});
|
||||
|
||||
it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => {
|
||||
expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([
|
||||
list[0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('q 가 없거나 공백뿐이면 그대로 준다', () => {
|
||||
expect(filterRecipientSuggestions(list, undefined)).toBe(list);
|
||||
expect(filterRecipientSuggestions(list, ' ')).toBe(list);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
Correspondence,
|
||||
DirectoryContact,
|
||||
RecipientSuggestion,
|
||||
} from './mail.types';
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천".
|
||||
*
|
||||
* - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다
|
||||
* - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기)
|
||||
* - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다
|
||||
*/
|
||||
|
||||
/** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */
|
||||
const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
/** 한 사람에게 기억하는 추천 수 상한 */
|
||||
export const MAX_RECIPIENT_SUGGESTIONS = 1000;
|
||||
|
||||
/** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */
|
||||
const NOREPLY_LOCAL_PART =
|
||||
/^(?:no[-_.]?reply|do[-_.]?not[-_.]?reply|mailer[-_.]?daemon)(?:[-_.+].*)?$/i;
|
||||
|
||||
export interface RankingInput {
|
||||
/** 요청한 직원 주소 */
|
||||
readonly me: string;
|
||||
/** 추천하지 않는 주소 — 게이트웨이·메일 엔진 관리자·막은 계정 (소문자) */
|
||||
readonly excluded: ReadonlySet<string>;
|
||||
readonly directory: readonly DirectoryContact[];
|
||||
readonly correspondence: readonly Correspondence[];
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
interface Tally {
|
||||
score: number;
|
||||
latestAt: number;
|
||||
/** 가장 최근에 본 표시 이름 (비어 있지 않은 것) */
|
||||
name: string | null;
|
||||
nameAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cleanName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */
|
||||
export function isSuggestable(
|
||||
email: string,
|
||||
me: string,
|
||||
excluded: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const at = email.lastIndexOf('@');
|
||||
if (at <= 0 || at === email.length - 1) return false;
|
||||
if (email === me || excluded.has(email)) return false;
|
||||
return !NOREPLY_LOCAL_PART.test(email.slice(0, at));
|
||||
}
|
||||
|
||||
function tally(
|
||||
correspondence: readonly Correspondence[],
|
||||
now: number,
|
||||
): Map<string, Tally> {
|
||||
const tallies = new Map<string, Tally>();
|
||||
for (const item of correspondence) {
|
||||
const email = normalizeEmail(item.email);
|
||||
const at = Date.parse(item.at);
|
||||
const when = Number.isNaN(at) ? 0 : at;
|
||||
const age = Math.max(0, now - when);
|
||||
const weight = Math.pow(0.5, age / RECENCY_HALF_LIFE_MS);
|
||||
|
||||
const entry = tallies.get(email) ?? {
|
||||
score: 0,
|
||||
latestAt: 0,
|
||||
name: null,
|
||||
nameAt: -1,
|
||||
};
|
||||
entry.score += weight;
|
||||
entry.latestAt = Math.max(entry.latestAt, when);
|
||||
const name = cleanName(item.name);
|
||||
if (name !== null && when >= entry.nameAt) {
|
||||
entry.name = name;
|
||||
entry.nameAt = when;
|
||||
}
|
||||
tallies.set(email, entry);
|
||||
}
|
||||
return tallies;
|
||||
}
|
||||
|
||||
const byName = new Intl.Collator('ko');
|
||||
|
||||
/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */
|
||||
export function rankRecipientSuggestions(
|
||||
input: RankingInput,
|
||||
): RecipientSuggestion[] {
|
||||
const me = normalizeEmail(input.me);
|
||||
const tallies = tally(input.correspondence, input.now.getTime());
|
||||
const scoreOf = (email: string) => tallies.get(email)?.score ?? 0;
|
||||
|
||||
const directory = new Map<string, string | null>();
|
||||
for (const contact of input.directory) {
|
||||
const email = normalizeEmail(contact.email);
|
||||
if (!isSuggestable(email, me, input.excluded) || directory.has(email)) {
|
||||
continue;
|
||||
}
|
||||
directory.set(
|
||||
email,
|
||||
cleanName(contact.name) ?? tallies.get(email)?.name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
const colleagues: RecipientSuggestion[] = [...directory]
|
||||
.map(([email, name]) => ({ name, email, source: 'directory' as const }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
scoreOf(b.email) - scoreOf(a.email) ||
|
||||
byName.compare(a.name ?? a.email, b.name ?? b.email) ||
|
||||
a.email.localeCompare(b.email),
|
||||
);
|
||||
|
||||
const recent: RecipientSuggestion[] = [...tallies]
|
||||
.filter(
|
||||
([email]) =>
|
||||
!directory.has(email) && isSuggestable(email, me, input.excluded),
|
||||
)
|
||||
.sort(
|
||||
([emailA, a], [emailB, b]) =>
|
||||
b.score - a.score ||
|
||||
b.latestAt - a.latestAt ||
|
||||
emailA.localeCompare(emailB),
|
||||
)
|
||||
.map(([email, entry]) => ({
|
||||
name: entry.name,
|
||||
email,
|
||||
source: 'recent' as const,
|
||||
}));
|
||||
|
||||
return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS);
|
||||
}
|
||||
|
||||
/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */
|
||||
function folded(text: string): string {
|
||||
return text.normalize('NFC').toLowerCase();
|
||||
}
|
||||
|
||||
/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */
|
||||
export function filterRecipientSuggestions(
|
||||
suggestions: readonly RecipientSuggestion[],
|
||||
query: string | undefined,
|
||||
): readonly RecipientSuggestion[] {
|
||||
const q = query ? folded(query.trim()) : '';
|
||||
if (q === '') return suggestions;
|
||||
return suggestions.filter(
|
||||
(suggestion) =>
|
||||
folded(suggestion.email).includes(q) ||
|
||||
(suggestion.name !== null && folded(suggestion.name).includes(q)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
decodeSuggestionCursor,
|
||||
encodeSuggestionCursor,
|
||||
} from './recipient-suggestion-cursor';
|
||||
|
||||
function reasonOf(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'VAL_001') {
|
||||
return error.context?.reason;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const encoded = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
|
||||
describe('받는 사람 추천 커서', () => {
|
||||
it('같은 검색어로 되돌리면 위치가 그대로다 (검색어 앞뒤 공백은 부르는 쪽이 지운다)', () => {
|
||||
for (const q of [undefined, '도윤', 'partner.test']) {
|
||||
expect(decodeSuggestionCursor(encodeSuggestionCursor(40, q), q)).toBe(40);
|
||||
}
|
||||
// 검색어 없음과 빈 검색어는 같은 범위다
|
||||
expect(
|
||||
decodeSuggestionCursor(encodeSuggestionCursor(3, undefined), ''),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('다른 검색어의 커서는 VAL_001 — 걸러진 목록이 달라 위치가 맞지 않는다', () => {
|
||||
const cursor = encodeSuggestionCursor(20, '도윤');
|
||||
|
||||
expect(reasonOf(() => decodeSuggestionCursor(cursor, '서준'))).toBe(
|
||||
'invalid-suggestion-cursor:other-query',
|
||||
);
|
||||
expect(reasonOf(() => decodeSuggestionCursor(cursor, undefined))).toBe(
|
||||
'invalid-suggestion-cursor:other-query',
|
||||
);
|
||||
});
|
||||
|
||||
it('형식이 틀리면 VAL_001 — 너무 김 · JSON 아님 · 모르는 판 · 음수·소수 위치', () => {
|
||||
const scope = JSON.parse(
|
||||
Buffer.from(encodeSuggestionCursor(0, undefined), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
) as { s: string };
|
||||
|
||||
expect(
|
||||
reasonOf(() => decodeSuggestionCursor('x'.repeat(257), undefined)),
|
||||
).toBe('invalid-suggestion-cursor:too-long');
|
||||
expect(reasonOf(() => decodeSuggestionCursor('@@@', undefined))).toBe(
|
||||
'invalid-suggestion-cursor:not-json',
|
||||
);
|
||||
for (const payload of [
|
||||
null,
|
||||
{ v: 2, o: 0, s: scope.s },
|
||||
{ v: 1, o: -1, s: scope.s },
|
||||
{ v: 1, o: 1.5, s: scope.s },
|
||||
{ v: 1, o: '1', s: scope.s },
|
||||
{ v: 1, o: 1 },
|
||||
]) {
|
||||
expect(
|
||||
reasonOf(() => decodeSuggestionCursor(encoded(payload), undefined)),
|
||||
).toBe('invalid-suggestion-cursor:malformed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치.
|
||||
* 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식).
|
||||
* 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다).
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 256;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
o: number;
|
||||
s: string;
|
||||
}
|
||||
|
||||
function scopeHash(query: string | undefined): string {
|
||||
return createHash('sha256')
|
||||
.update(query ?? '')
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-suggestion-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
export function encodeSuggestionCursor(
|
||||
offset: number,
|
||||
query: string | undefined,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
o: offset,
|
||||
s: scopeHash(query),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */
|
||||
export function decodeSuggestionCursor(
|
||||
cursor: string,
|
||||
query: string | undefined,
|
||||
): number {
|
||||
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, o, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
typeof o === 'number' &&
|
||||
Number.isSafeInteger(o) &&
|
||||
o >= 0 &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('malformed');
|
||||
if (s !== scopeHash(query)) throw invalidCursor('other-query');
|
||||
return o;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Header, Query, 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 { RecipientSuggestionPageDto } from './dto/mail-response.dto';
|
||||
import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
|
||||
// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다.
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('recipient-suggestions')
|
||||
export class RecipientSuggestionController {
|
||||
constructor(private readonly suggestions: RecipientSuggestionService) {}
|
||||
|
||||
@Get()
|
||||
// 주고받은 사람 목록 — 공유 캐시·브라우저 캐시 어디에도 남기지 않는다
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
@ApiOperation({
|
||||
summary: '받는 사람 추천 (커서 페이지네이션)',
|
||||
description:
|
||||
'사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' +
|
||||
'묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' +
|
||||
'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(RecipientSuggestionPageDto, {
|
||||
description: '추천 목록 (total 은 걸러진 개수)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', 'q 100자 초과 · 잘못된 커서 · limit 범위 밖')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: RecipientSuggestionQueryDto,
|
||||
) {
|
||||
return this.suggestions.suggest(user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
import type { Correspondence, DirectoryContact } from './mail.types';
|
||||
import {
|
||||
RecipientSuggestionService,
|
||||
SUGGESTION_CACHE_MAX_USERS,
|
||||
SUGGESTION_CACHE_TTL_MS,
|
||||
} from './recipient-suggestions.service';
|
||||
|
||||
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
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']),
|
||||
};
|
||||
|
||||
const NOW = Date.parse('2026-10-27T03:00:00Z');
|
||||
const daysAgo = (days: number) =>
|
||||
new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const query = (
|
||||
overrides: Partial<RecipientSuggestionQueryDto> = {},
|
||||
): RecipientSuggestionQueryDto => ({ limit: 20, ...overrides });
|
||||
|
||||
describe('RecipientSuggestionService', () => {
|
||||
let listCorrespondents: jest.Mock<
|
||||
Promise<Correspondence[]>,
|
||||
Parameters<MailProvider['listCorrespondents']>
|
||||
>;
|
||||
let listContacts: jest.Mock<Promise<DirectoryContact[]>, [number?]>;
|
||||
let service: RecipientSuggestionService;
|
||||
let debugSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
listCorrespondents = jest
|
||||
.fn<
|
||||
Promise<Correspondence[]>,
|
||||
Parameters<MailProvider['listCorrespondents']>
|
||||
>()
|
||||
.mockResolvedValue([
|
||||
{ name: '고객사', email: 'client@partner.test', at: daysAgo(1) },
|
||||
{ name: null, email: 'Client@Partner.test', at: daysAgo(2) },
|
||||
{ name: '이도윤', email: 'doyun.lee@arasystem.test', at: daysAgo(1) },
|
||||
{ name: null, email: 'vendor@supply.test', at: daysAgo(90) },
|
||||
// 나·게이트웨이·관리자·자동 발송 주소는 추천하지 않는다
|
||||
{ name: '나', email: 'ARA.KIM@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'mail-gateway@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'admin@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'no-reply@service.test', at: daysAgo(0) },
|
||||
]);
|
||||
listContacts = jest
|
||||
.fn<Promise<DirectoryContact[]>, [number?]>()
|
||||
.mockResolvedValue([
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
{ email: 'ara.kim@arasystem.test', name: '김아라' },
|
||||
]);
|
||||
service = new RecipientSuggestionService(
|
||||
{ listCorrespondents } as unknown as MailProvider,
|
||||
settings,
|
||||
{ listContacts } as unknown as MailDirectoryService,
|
||||
);
|
||||
debugSpy = jest
|
||||
.spyOn(Logger.prototype, 'debug')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('사내 → 최근 순으로, 그 직원 자신의 메일함에서 모은다 — 나·게이트웨이·관리자·noreply 는 빼고, 두 쪽에 다 있으면 사내로 한 번', async () => {
|
||||
const page = await service.suggest(ARA, query());
|
||||
|
||||
expect(page.items).toEqual([
|
||||
// 사내 묶음 안에서는 주고받은 사람이 먼저
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{
|
||||
name: '박서준',
|
||||
email: 'seojun.park@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
// 대소문자만 다른 주소는 한 사람 — 두 번 주고받았다
|
||||
{ name: '고객사', email: 'client@partner.test', source: 'recent' },
|
||||
{ name: null, email: 'vendor@supply.test', source: 'recent' },
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 4,
|
||||
});
|
||||
expect(listCorrespondents).toHaveBeenCalledWith('ara.kim@arasystem.test', {
|
||||
sent: 500,
|
||||
received: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('커서 페이지네이션 — 걸러진 목록의 다음 위치, total 은 걸러진 개수', async () => {
|
||||
const first = await service.suggest(ARA, query({ limit: 3 }));
|
||||
|
||||
expect(first.items).toHaveLength(3);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
|
||||
const cursor = first.pageInfo.nextCursor;
|
||||
if (!cursor) throw new Error('다음 커서가 없다');
|
||||
|
||||
const second = await service.suggest(ARA, query({ limit: 3, cursor }));
|
||||
expect(second.items.map((item) => item.email)).toEqual([
|
||||
'vendor@supply.test',
|
||||
]);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('q 는 이름·주소에 든 것만 (대소문자 무시) — 다른 q 의 커서는 VAL_001', async () => {
|
||||
const page = await service.suggest(
|
||||
ARA,
|
||||
query({ q: ' PARTNER ', limit: 1 }),
|
||||
);
|
||||
expect(page.items.map((item) => item.email)).toEqual([
|
||||
'client@partner.test',
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const byName = await service.suggest(ARA, query({ q: '서준', limit: 1 }));
|
||||
expect(byName.items.map((item) => item.email)).toEqual([
|
||||
'seojun.park@arasystem.test',
|
||||
]);
|
||||
|
||||
const all = await service.suggest(ARA, query({ limit: 1 }));
|
||||
const cursor = all.pageInfo.nextCursor ?? '';
|
||||
await expect(
|
||||
service.suggest(ARA, query({ q: '이', cursor })),
|
||||
).rejects.toMatchObject({
|
||||
code: 'VAL_001',
|
||||
context: { reason: 'invalid-suggestion-cursor:other-query' },
|
||||
});
|
||||
});
|
||||
|
||||
it('틀린 커서는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
for (const cursor of ['not-a-cursor', 'x'.repeat(300)]) {
|
||||
const error = await service
|
||||
.suggest(ARA, query({ cursor }))
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(BusinessException);
|
||||
expect((error as BusinessException).code).toBe('VAL_001');
|
||||
}
|
||||
expect(listCorrespondents).not.toHaveBeenCalled();
|
||||
expect(listContacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사용자마다 10분 기억한다 — 그 안에서는 다시 모으지 않고, 지나면 다시 모은다', async () => {
|
||||
await service.suggest(ARA, query());
|
||||
await service.suggest({ ...ARA, email: 'Ara.Kim@arasystem.test' }, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
|
||||
await service.suggest(ARA, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 다른 사람은 따로 모은다 (주고받은 주소가 섞이지 않게)
|
||||
await service.suggest(
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
query(),
|
||||
);
|
||||
expect(listCorrespondents).toHaveBeenLastCalledWith(
|
||||
'doyun.lee@arasystem.test',
|
||||
{ sent: 500, received: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('메일을 보내면(invalidate) 잊는다 — 다음 요청에 다시 모은다', async () => {
|
||||
await service.suggest(ARA, query());
|
||||
service.invalidate('ARA.KIM@arasystem.test');
|
||||
await service.suggest(ARA, query());
|
||||
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('같은 사람의 동시 요청은 한 번만 모은다', async () => {
|
||||
let finish!: (value: Correspondence[]) => void;
|
||||
listCorrespondents.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
);
|
||||
|
||||
const first = service.suggest(ARA, query());
|
||||
const second = service.suggest(ARA, query({ q: '도윤' }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finish([]);
|
||||
|
||||
await expect(first).resolves.toMatchObject({ pageInfo: { total: 2 } });
|
||||
await expect(second).resolves.toMatchObject({ pageInfo: { total: 1 } });
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('모으는 중에 보냈으면 그 결과는 기억하지 않는다 — 보낸 뒤의 요청은 새로 모은다', async () => {
|
||||
let finish!: (value: Correspondence[]) => void;
|
||||
listCorrespondents.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
);
|
||||
|
||||
const before = service.suggest(ARA, query());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
service.invalidate(ARA.email);
|
||||
const after = service.suggest(ARA, query());
|
||||
finish([]);
|
||||
|
||||
await before;
|
||||
await after;
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 보낸 뒤에 모은 결과는 기억한다
|
||||
await service.suggest(ARA, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('엔진 장애는 그대로 던지고 기억하지 않는다 — 다음 요청은 다시 묻는다', async () => {
|
||||
listCorrespondents.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
|
||||
await expect(service.suggest(ARA, query())).rejects.toMatchObject({
|
||||
code: 'MAIL_001',
|
||||
});
|
||||
await expect(service.suggest(ARA, query())).resolves.toMatchObject({
|
||||
pageInfo: { total: 4 },
|
||||
});
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it(`기억하는 사람은 ${SUGGESTION_CACHE_MAX_USERS}명까지 — 가장 오래 쓰지 않은 사람부터 잊는다`, async () => {
|
||||
const userAt = (index: number) => ({
|
||||
email: `user${index}@arasystem.test`,
|
||||
name: `직원${index}`,
|
||||
});
|
||||
for (let index = 0; index < SUGGESTION_CACHE_MAX_USERS; index += 1) {
|
||||
await service.suggest(userAt(index), query());
|
||||
}
|
||||
// 0번을 다시 쓴다 — 이제 가장 오래 쓰지 않은 사람은 1번이다
|
||||
await service.suggest(userAt(0), query());
|
||||
await service.suggest(userAt(SUGGESTION_CACHE_MAX_USERS), query());
|
||||
listCorrespondents.mockClear();
|
||||
|
||||
await service.suggest(userAt(0), query());
|
||||
expect(listCorrespondents).not.toHaveBeenCalled();
|
||||
await service.suggest(userAt(1), query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('기억할 때 만료된 목록을 함께 지운다 — 다시 오지 않은 사람의 목록이 남지 않는다', async () => {
|
||||
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
|
||||
await service.suggest(ARA, query());
|
||||
expect(cache.has(ARA.email)).toBe(true);
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
|
||||
await service.suggest(
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
query(),
|
||||
);
|
||||
|
||||
expect([...cache.keys()]).toEqual(['doyun.lee@arasystem.test']);
|
||||
});
|
||||
|
||||
it('줄지 않는 표를 두지 않는다 — 모으기가 끝나면 빠지고, 잊기만 한 사람은 아무 데도 남지 않는다', async () => {
|
||||
const collecting = Reflect.get(service, 'collecting') as Map<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
|
||||
|
||||
await service.suggest(ARA, query());
|
||||
expect(collecting.size).toBe(0);
|
||||
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
service.invalidate(`user${index}@arasystem.test`);
|
||||
}
|
||||
service.invalidate(ARA.email);
|
||||
expect(collecting.size).toBe(0);
|
||||
expect(cache.size).toBe(0);
|
||||
// 잊은 횟수를 사람마다 세던 표(generations)는 없앴다
|
||||
expect(Reflect.get(service, 'generations')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그에는 개수만 남긴다 — 주소·이름·검색어 없음', async () => {
|
||||
await service.suggest(ARA, query({ q: '고객' }));
|
||||
|
||||
const logged = debugSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(logged).toContain('사내 2명, 최근 2명');
|
||||
expect(logged).not.toMatch(/partner|doyun|seojun|고객|이도윤/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import {
|
||||
SUGGESTION_RECEIVED_SCAN,
|
||||
SUGGESTION_SENT_SCAN,
|
||||
} from './mail.constants';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type { RecipientSuggestion } from './mail.types';
|
||||
import {
|
||||
filterRecipientSuggestions,
|
||||
rankRecipientSuggestions,
|
||||
} from './recipient-ranking';
|
||||
import {
|
||||
decodeSuggestionCursor,
|
||||
encodeSuggestionCursor,
|
||||
} from './recipient-suggestion-cursor';
|
||||
|
||||
/** 사용자별 추천 목록을 기억하는 시간 */
|
||||
export const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* 기억하는 사용자 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다.
|
||||
* 한 사람의 목록은 1,000개까지라(recipient-ranking.ts) 사람당 수십~백수십 KB — 상한이 메모리 상한이다.
|
||||
*/
|
||||
export const SUGGESTION_CACHE_MAX_USERS = 500;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly expiresAt: number;
|
||||
readonly items: readonly RecipientSuggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모으는 중인 목록. `token` 은 이 모으기의 표 — 그 사람을 잊으면(invalidate) 표에서 빠지므로,
|
||||
* 끝났을 때 표가 그대로인 모으기만 결과를 기억한다 (잊기 전에 시작한 모으기의 옛 결과를 남기지 않는다).
|
||||
*/
|
||||
interface Collecting {
|
||||
readonly token: object;
|
||||
readonly promise: Promise<readonly RecipientSuggestion[]>;
|
||||
}
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 (계약 2절 "받는 사람 추천") — 사내 목록 + 그 사람이 최근 주고받은 주소.
|
||||
*
|
||||
* - 주고받은 주소는 **요청한 직원 자신의** 메일함에서만 모은다 (대리 접근이 그 사람으로만 들어간다). 다른 직원의 것과 섞지 않는다
|
||||
* - 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다
|
||||
* - 로그에는 개수만 남긴다 — 주소·검색어는 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 (틀리지는 않고, 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 줄 수 있다).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RecipientSuggestionService {
|
||||
private readonly logger = new Logger(RecipientSuggestionService.name);
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
/** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */
|
||||
private readonly collecting = new Map<string, Collecting>();
|
||||
private readonly excluded: ReadonlySet<string>;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) settings: MailSettings,
|
||||
private readonly directory: MailDirectoryService,
|
||||
) {
|
||||
this.excluded = new Set([
|
||||
...settings.blockedAccounts,
|
||||
...(settings.gatewayUser ? [settings.gatewayUser] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
async suggest(
|
||||
user: AuthenticatedUser,
|
||||
query: RecipientSuggestionQueryDto,
|
||||
): Promise<PaginatedResult<RecipientSuggestion>> {
|
||||
const q = query.q?.trim() ? query.q.trim() : undefined;
|
||||
// 커서는 엔진에 묻기 전에 확인한다 — 틀린 커서로 목록을 모으지 않게
|
||||
const start = query.cursor ? decodeSuggestionCursor(query.cursor, q) : 0;
|
||||
|
||||
const matched = filterRecipientSuggestions(
|
||||
await this.suggestionsOf(user.email),
|
||||
q,
|
||||
);
|
||||
const items = matched.slice(start, start + query.limit);
|
||||
const next = start + items.length;
|
||||
const hasNext = next < matched.length;
|
||||
return toPaginatedResult([...items], {
|
||||
hasNext,
|
||||
nextCursor: hasNext ? encodeSuggestionCursor(next, q) : null,
|
||||
total: matched.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람이 메일을 보냈다 — 기억한 목록을 잊는다 (다음 요청에 다시 모은다) */
|
||||
invalidate(userEmail: string): void {
|
||||
const key = normalizeUser(userEmail);
|
||||
this.cache.delete(key);
|
||||
// 모으는 중이던 것도 잊는다 — 끝나도 기억하지 않고, 이 뒤의 요청은 보낸 메일까지 넣어 새로 모은다
|
||||
this.collecting.delete(key);
|
||||
}
|
||||
|
||||
private suggestionsOf(
|
||||
userEmail: string,
|
||||
): Promise<readonly RecipientSuggestion[]> {
|
||||
const key = normalizeUser(userEmail);
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
// 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, cached);
|
||||
return Promise.resolve(cached.items);
|
||||
}
|
||||
|
||||
const pending = this.collecting.get(key);
|
||||
if (pending) return pending.promise;
|
||||
|
||||
const token = {};
|
||||
const promise = this.collect(key, token).finally(() => {
|
||||
// 그사이 잊고 새로 모으기 시작했으면 그것은 그대로 둔다
|
||||
if (this.collecting.get(key)?.token === token)
|
||||
this.collecting.delete(key);
|
||||
});
|
||||
this.collecting.set(key, { token, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async collect(
|
||||
user: string,
|
||||
token: object,
|
||||
): Promise<readonly RecipientSuggestion[]> {
|
||||
const [correspondence, directory] = await Promise.all([
|
||||
this.provider.listCorrespondents(user, {
|
||||
sent: SUGGESTION_SENT_SCAN,
|
||||
received: SUGGESTION_RECEIVED_SCAN,
|
||||
}),
|
||||
this.directory.listContacts(),
|
||||
]);
|
||||
const items = rankRecipientSuggestions({
|
||||
me: user,
|
||||
excluded: this.excluded,
|
||||
directory,
|
||||
correspondence,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
// 모으는 사이 잊었으면(그 사람이 메일을 보냈다) 기억하지 않는다
|
||||
if (this.collecting.get(user)?.token === token) this.remember(user, items);
|
||||
// 반복되는 정상 흐름이라 debug — 개수만 (주소·검색어는 남기지 않는다)
|
||||
const colleagues = items.filter(
|
||||
(item) => item.source === 'directory',
|
||||
).length;
|
||||
this.logger.debug(
|
||||
`${traceTag()} 받는 사람 추천을 모았습니다: 사내 ${colleagues}명, 최근 ${items.length - colleagues}명`,
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
private remember(user: string, items: readonly RecipientSuggestion[]): void {
|
||||
const now = Date.now();
|
||||
// 만료된 목록은 그때그때 지운다 — 다시 오지 않은 사람의 목록이 상한까지 쌓여 메모리를 잡지 않게
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.expiresAt <= now) this.cache.delete(key);
|
||||
}
|
||||
this.cache.delete(user);
|
||||
this.cache.set(user, { expiresAt: now + SUGGESTION_CACHE_TTL_MS, items });
|
||||
while (this.cache.size > SUGGESTION_CACHE_MAX_USERS) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,42 @@
|
||||
import { maskEmail } from './mask';
|
||||
import { maskEmail, maskUrlForLog } from './mask';
|
||||
|
||||
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
|
||||
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
|
||||
),
|
||||
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
|
||||
// + 는 공백 · 주소 형태의 검색어
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/messages?mailboxId=a&q=ara.kim%40arasystem.it.kr+%EC%9D%BC%EC%A0%95',
|
||||
),
|
||||
).toBe('/api/messages?mailboxId=a&q=<26자>');
|
||||
});
|
||||
|
||||
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
|
||||
expect(
|
||||
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
|
||||
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
|
||||
expect(
|
||||
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
|
||||
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
|
||||
});
|
||||
|
||||
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
|
||||
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
|
||||
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
|
||||
'/api/messages?mailboxId=a&limit=20',
|
||||
);
|
||||
// q 로 시작하는 다른 이름은 가리지 않는다
|
||||
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
|
||||
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
|
||||
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
|
||||
// 값 안의 = 도 값이다
|
||||
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
/** 가린 부분을 표시하는 문자열 — 원래 길이를 드러내지 않도록 고정 길이로 쓴다 */
|
||||
const MASK = '***';
|
||||
|
||||
/**
|
||||
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
|
||||
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
|
||||
*/
|
||||
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
|
||||
|
||||
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
|
||||
function decodeQueryPart(part: string): string {
|
||||
try {
|
||||
return decodeURIComponent(part.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 주소를 로그용으로 가린다. 첫 글자와 도메인만 남긴다.
|
||||
*
|
||||
@@ -22,3 +37,28 @@ export function maskEmail(email: string): string {
|
||||
const [first] = Array.from(email.slice(0, at));
|
||||
return `${first}${MASK}@${email.slice(at + 1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
|
||||
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
|
||||
*
|
||||
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
|
||||
*/
|
||||
export function maskUrlForLog(url: string): string {
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart < 0) return url;
|
||||
|
||||
const query = url
|
||||
.slice(queryStart + 1)
|
||||
.split('&')
|
||||
.map((pair) => {
|
||||
const separator = pair.indexOf('=');
|
||||
const name = separator < 0 ? pair : pair.slice(0, separator);
|
||||
if (!REDACTED_QUERY_NAME.test(decodeQueryPart(name))) return pair;
|
||||
const value =
|
||||
separator < 0 ? '' : decodeQueryPart(pair.slice(separator + 1));
|
||||
return `${name}=<${value.length}자>`;
|
||||
})
|
||||
.join('&');
|
||||
return `${url.slice(0, queryStart)}?${query}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { settledWithin } from './settle';
|
||||
|
||||
describe('settledWithin (정해진 시간까지만 기다리기)', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('시간 안에 끝나면 true — 실패로 끝나도 끝난 것이다', async () => {
|
||||
await expect(settledWithin(Promise.resolve('done'), 1_000)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
const failed = Promise.reject(new Error('engine down'));
|
||||
await expect(settledWithin(failed, 1_000)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('시간이 지나면 false — 작업은 멈추지 않고 뒤에서 끝난다', async () => {
|
||||
let finish!: () => void;
|
||||
let finished = false;
|
||||
const work = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
|
||||
const waited = settledWithin(work, 5_000);
|
||||
jest.advanceTimersByTime(5_000);
|
||||
await expect(waited).resolves.toBe(false);
|
||||
|
||||
finish();
|
||||
await work;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 정해진 시간까지만 기다린다 — 그 안에 끝나면 true, 아니면 false (작업은 멈추지 않고 뒤에서 계속된다).
|
||||
* 작업이 실패해도 "끝난 것"으로 본다 — 실패 처리(로그 등)는 작업 쪽이 한다. 이 함수는 결과를 바꾸지 않는다.
|
||||
* 기다림 타이머는 프로세스를 붙잡지 않는다(unref) — 종료가 늦어지지 않게.
|
||||
*/
|
||||
export function settledWithin(
|
||||
work: Promise<unknown>,
|
||||
ms: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), ms);
|
||||
timer.unref();
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
work.then(finish, finish);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user