diff --git a/backend/src/app.setup.spec.ts b/backend/src/app.setup.spec.ts index 40e93c0..15e28a3 100644 --- a/backend/src/app.setup.spec.ts +++ b/backend/src/app.setup.spec.ts @@ -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 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => { diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts index 360cdce..c51d169 100644 --- a/backend/src/app.setup.ts +++ b/backend/src/app.setup.ts @@ -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): string { + return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase(); +} /** * 발송 요청인가 — `POST /api/messages`. - * 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다: Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다. + * 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다. */ export function isSendMessageRequest( req: Pick, ): 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, +): 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 }); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index a7fbfd5..481aa7b 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; export const ERROR_CODES = { diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index 08c095f..844618a 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -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')); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 2fdbc59..ae1d420 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -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 = diff --git a/backend/src/migrations/1793084109734-AddMailSignaturesAndDirectory.ts b/backend/src/migrations/1793084109734-AddMailSignaturesAndDirectory.ts new file mode 100644 index 0000000..ddb687d --- /dev/null +++ b/backend/src/migrations/1793084109734-AddMailSignaturesAndDirectory.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index eeeed13..341518b 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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: [ diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index ceaee79..07d274e 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -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>; 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); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 928cba3..26c7ebc 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다. * diff --git a/backend/src/modules/mail-directory/mail-directory.entity.ts b/backend/src/modules/mail-directory/mail-directory.entity.ts new file mode 100644 index 0000000..70ac8fa --- /dev/null +++ b/backend/src/modules/mail-directory/mail-directory.entity.ts @@ -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; +} diff --git a/backend/src/modules/mail-directory/mail-directory.module.ts b/backend/src/modules/mail-directory/mail-directory.module.ts new file mode 100644 index 0000000..742d523 --- /dev/null +++ b/backend/src/modules/mail-directory/mail-directory.module.ts @@ -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 {} diff --git a/backend/src/modules/mail-directory/mail-directory.service.spec.ts b/backend/src/modules/mail-directory/mail-directory.service.spec.ts new file mode 100644 index 0000000..3e1cfc0 --- /dev/null +++ b/backend/src/modules/mail-directory/mail-directory.service.spec.ts @@ -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); + }); + + 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, + }); + }); +}); diff --git a/backend/src/modules/mail-directory/mail-directory.service.ts b/backend/src/modules/mail-directory/mail-directory.service.ts new file mode 100644 index 0000000..d57a4c3 --- /dev/null +++ b/backend/src/modules/mail-directory/mail-directory.service.ts @@ -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, + ) {} + + /** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */ + async recordLogin( + contact: DirectoryContact, + now: Date = new Date(), + ): Promise { + 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 { + 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 })); + } +} diff --git a/backend/src/modules/mail-signature/dto/mail-signature.dto.ts b/backend/src/modules/mail-signature/dto/mail-signature.dto.ts new file mode 100644 index 0000000..02fc855 --- /dev/null +++ b/backend/src/modules/mail-signature/dto/mail-signature.dto.ts @@ -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 — 서식 본문과 같은 허용 목록 + 내 서명 그림(). ` + + `정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` + + '서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다', + nullable: true, + type: String, + example: + '

김아라 · 아라시스템

', + }) + @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 의 에 쓴다', + 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; +} diff --git a/backend/src/modules/mail-signature/mail-signature-image.entity.ts b/backend/src/modules/mail-signature/mail-signature-image.entity.ts new file mode 100644 index 0000000..b1bf232 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature-image.entity.ts @@ -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; +/** 서버가 만든 파일 이름 — `.<확장자>` */ +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; +} diff --git a/backend/src/modules/mail-signature/mail-signature.cleanup.spec.ts b/backend/src/modules/mail-signature/mail-signature.cleanup.spec.ts new file mode 100644 index 0000000..8d16a78 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.cleanup.spec.ts @@ -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, + [Date] + >; + deleteUnattachedImages: jest.Mock< + Promise[]>, + [readonly string[]] + >; + listFileNames: jest.Mock>, []>; + }; + 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, [Date]>() + .mockResolvedValue([]), + deleteUnattachedImages: jest + .fn< + Promise[]>, + [readonly string[]] + >() + .mockImplementation((ids) => + Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))), + ), + listFileNames: jest + .fn>, []>() + .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([]); + }); +}); diff --git a/backend/src/modules/mail-signature/mail-signature.cleanup.ts b/backend/src/modules/mail-signature/mail-signature.cleanup.ts new file mode 100644 index 0000000..a1d4e57 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.cleanup.ts @@ -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 { + 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; + } + } +} diff --git a/backend/src/modules/mail-signature/mail-signature.constants.ts b/backend/src/modules/mail-signature/mail-signature.constants.ts new file mode 100644 index 0000000..4fc827a --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.constants.ts @@ -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'; diff --git a/backend/src/modules/mail-signature/mail-signature.controller.ts b/backend/src/modules/mail-signature/mail-signature.controller.ts new file mode 100644 index 0000000..a9d7be5 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.controller.ts @@ -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 { + 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, + }); + } +} diff --git a/backend/src/modules/mail-signature/mail-signature.entity.ts b/backend/src/modules/mail-signature/mail-signature.entity.ts new file mode 100644 index 0000000..ac9f54a --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.entity.ts @@ -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; +} diff --git a/backend/src/modules/mail-signature/mail-signature.module.ts b/backend/src/modules/mail-signature/mail-signature.module.ts new file mode 100644 index 0000000..ecf3159 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.module.ts @@ -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 {} diff --git a/backend/src/modules/mail-signature/mail-signature.repository.ts b/backend/src/modules/mail-signature/mail-signature.repository.ts new file mode 100644 index 0000000..992c66b --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.repository.ts @@ -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, + @InjectRepository(MailSignatureImageEntity) + private readonly images: Repository, + private readonly dataSource: DataSource, + ) {} + + findSignature(userEmail: string): Promise { + return this.signatures.findOneBy({ userEmail }); + } + + /** 그 사람의 그림 — 올린 순서대로 */ + listImages(userEmail: string): Promise { + return this.images.find({ + where: { userEmail }, + order: { createdAt: 'ASC' }, + }); + } + + findImage( + id: string, + userEmail: string, + ): Promise { + return this.images.findOneBy({ id, userEmail }); + } + + countImages(userEmail: string): Promise { + return this.images.countBy({ userEmail }); + } + + async insertImage(image: MailSignatureImageEntity): Promise { + 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 { + 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 { + return this.images.find({ + where: { attachedAt: IsNull(), createdAt: LessThan(before) }, + }); + } + + /** + * 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다). + * 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다). + * @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다 + */ + async deleteUnattachedImages( + ids: readonly string[], + ): Promise[]> { + 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> { + const rows = await this.images.find({ select: { fileName: true } }); + return new Set(rows.map((row) => row.fileName)); + } +} diff --git a/backend/src/modules/mail-signature/mail-signature.service.spec.ts b/backend/src/modules/mail-signature/mail-signature.service.spec.ts new file mode 100644 index 0000000..0dd5470 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.service.spec.ts @@ -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(); + images: MailSignatureImageEntity[] = []; + /** 다음 insertImage 를 실패시킨다 */ + failNextInsert = false; + /** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */ + deletedBeforeNextSave: string | null = null; + readonly saveCalls: { + referenced: readonly string[]; + keepUploadedAfter: Date; + }[] = []; + + findSignature(user: string): Promise { + return Promise.resolve(this.signatures.get(user) ?? null); + } + + listImages(user: string): Promise { + return Promise.resolve(this.images.filter((row) => row.userEmail === user)); + } + + findImage( + id: string, + user: string, + ): Promise { + return Promise.resolve( + this.images.find((row) => row.id === id && row.userEmail === user) ?? + null, + ); + } + + countImages(user: string): Promise { + return Promise.resolve( + this.images.filter((row) => row.userEmail === user).length, + ); + } + + insertImage(row: MailSignatureImageEntity): Promise { + 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 { + 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, + ): 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 = {}, + ): Promise { + 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 = `

김아라

`; + + const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, { + bodyHtml: html, + body: '김아라 · 아라시스템', + isForNew: true, + isForReply: false, + }); + + expect(saved).toEqual({ + bodyHtml: `

김아라

`, + 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: `

서명

`, + body: null, + isForNew: true, + isForReply: true, + }); + + expect(saved.bodyHtml).toBe( + `

서명

`, + ); + 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: ``, + 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: ``, + 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: '

그림 없음

', + body: null, + isForNew: true, + isForReply: true, + }), + ).resolves.toMatchObject({ bodyHtml: '

그림 없음

', images: [] }); + expect(repository.images).not.toContainEqual(dropped); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('서명 그림 파일을 지우지 못했습니다'), + ); + }); + + it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => { + for (const bodyHtml of ['', '', 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) => ``; + 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][] = [ + [{ body: '가'.repeat(2_001) }, 'signature-text-too-long'], + [ + { bodyHtml: `

${'가'.repeat(10_000)}

` }, + 'signature-html-too-long', + ], + [ + { bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) }, + 'signature-html-too-long', + ], + [{ bodyHtml: ''.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: `

${'가'.repeat(10_000 - 7)}

`, + 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(''), '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([]); + }); + }); +}); diff --git a/backend/src/modules/mail-signature/mail-signature.service.ts b/backend/src/modules/mail-signature/mail-signature.service.ts new file mode 100644 index 0000000..cea78de --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.service.ts @@ -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 = { + 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>(); + + constructor( + private readonly repository: MailSignatureRepository, + private readonly storage: SignatureImageStorage, + ) {} + + async getSignature(userEmail: string): Promise { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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(user: string, task: () => Promise): Promise { + 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; + } +} diff --git a/backend/src/modules/mail-signature/mail-signature.types.ts b/backend/src/modules/mail-signature/mail-signature.types.ts new file mode 100644 index 0000000..c2fe761 --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signature.types.ts @@ -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; +} diff --git a/backend/src/modules/mail-signature/signature-image-format.spec.ts b/backend/src/modules/mail-signature/signature-image-format.spec.ts new file mode 100644 index 0000000..feefc30 --- /dev/null +++ b/backend/src/modules/mail-signature/signature-image-format.spec.ts @@ -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(''), + Buffer.from(''), + 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, + }); + }); +}); diff --git a/backend/src/modules/mail-signature/signature-image-format.ts b/backend/src/modules/mail-signature/signature-image-format.ts new file mode 100644 index 0000000..5b32803 --- /dev/null +++ b/backend/src/modules/mail-signature/signature-image-format.ts @@ -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, +): 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 +> = { + '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; +} diff --git a/backend/src/modules/mail-signature/signature-image-id.pipe.ts b/backend/src/modules/mail-signature/signature-image-id.pipe.ts new file mode 100644 index 0000000..908e99d --- /dev/null +++ b/backend/src/modules/mail-signature/signature-image-id.pipe.ts @@ -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 { + 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' }, + }); + } +} diff --git a/backend/src/modules/mail-signature/signature-image-storage.spec.ts b/backend/src/modules/mail-signature/signature-image-storage.spec.ts new file mode 100644 index 0000000..83f6523 --- /dev/null +++ b/backend/src/modules/mail-signature/signature-image-storage.spec.ts @@ -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); + } + }); +}); diff --git a/backend/src/modules/mail-signature/signature-image-storage.ts b/backend/src/modules/mail-signature/signature-image-storage.ts new file mode 100644 index 0000000..8eee941 --- /dev/null +++ b/backend/src/modules/mail-signature/signature-image-storage.ts @@ -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 { + 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 { + try { + return await readFile(this.pathOf(fileName)); + } catch (error) { + if (isMissing(error)) return null; + throw error; + } + } + + /** 지운다 — 이미 없으면 그대로 둔다 */ + async remove(fileName: string): Promise { + try { + await unlink(this.pathOf(fileName)); + } catch (error) { + if (!isMissing(error)) throw error; + } + } + + /** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */ + async list(): Promise { + 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); + } +} diff --git a/backend/src/modules/mail/draft.controller.ts b/backend/src/modules/mail/draft.controller.ts new file mode 100644 index 0000000..fd1cbc4 --- /dev/null +++ b/backend/src/modules/mail/draft.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index aab606d..c38f1cb 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -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; +} diff --git a/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts new file mode 100644 index 0000000..683ad26 --- /dev/null +++ b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts @@ -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; +} diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts index b66bf92..bf91134 100644 --- a/backend/src/modules/mail/dto/send-message.dto.ts +++ b/backend/src/modules/mail/dto/send-message.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/mail/html/inline-images.spec.ts b/backend/src/modules/mail/html/inline-images.spec.ts index 365682c..d7c40ca 100644 --- a/backend/src/modules/mail/html/inline-images.spec.ts +++ b/backend/src/modules/mail/html/inline-images.spec.ts @@ -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: '', + 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('')).toBe( diff --git a/backend/src/modules/mail/html/inline-images.ts b/backend/src/modules/mail/html/inline-images.ts index 945848b..9017655 100644 --- a/backend/src/modules/mail/html/inline-images.ts +++ b/backend/src/modules/mail/html/inline-images.ts @@ -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)); +} + /** * 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한. * (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다) diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts index bda59bb..a3601d5 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts @@ -106,7 +106,11 @@ describe('sanitizeMailHtml', () => { `y`, ); - expect(result).toEqual({ html: 'y', hasBlockedImages: false }); + expect(result).toEqual({ + html: 'y', + 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('

a

'); 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( + '

' + + '' + + '
배경
', + inline, + ); + + expect(result.embeddedContentIds).toEqual(new Set(['logo@x'])); + expect(result.html).toBe( + `

배경
`, + ); }); it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => { diff --git a/backend/src/modules/mail/html/sanitize-mail-html.ts b/backend/src/modules/mail/html/sanitize-mail-html.ts index db19f1d..15818b3 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.ts @@ -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; } const ALLOWED_TAGS = [ @@ -294,6 +299,7 @@ function resolveImage( attribs: sanitizeHtml.Attributes, inlineImages: ReadonlyMap, 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(); + 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 }; } diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts index 8380464..1004267 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts @@ -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( + `

서명

아라시스템 로고`, + options, + ), + ).toBe( + `

서명

아라시스템 로고`, + ); + }); + + it('주소의 대소문자·앞뒤 공백은 정규화한다 — 지문이 같은 그림에 같은 값이 되게', () => { + expect( + clean( + ``, + options, + ), + ).toBe(``); + }); + + it('width 는 1~600 정수만, alt 는 200자까지 — 형식이 틀리면 그 속성만 뺀다', () => { + for (const width of [ + '0', + '601', + '96.5', + '96px', + '50%', + '096', + '-1', + '', + ]) { + expect( + clean(``, options), + ).toBe(``); + } + expect(clean(``, options)).toBe( + ``, + ); + expect( + clean(`${'가'.repeat(200)}`, options), + ).toBe(`${'가'.repeat(200)}`); + expect( + clean(`${'가'.repeat(201)}`, options), + ).toBe(``); + }); + + it('남의 그림·찾지 못한 그림·서명 그림이 아닌 주소는 태그째 지운다', () => { + for (const img of [ + ``, + ``, + ``, + ``, + '', + '', + '', + '', + ]) { + expect(clean(`

앞${img}뒤

`, options)).toBe('

앞뒤

'); + } + }); + + it('그림 id 를 주지 않으면 서명 그림도 지운다 (기본값 — 그림 없음)', () => { + expect(clean(`

앞뒤

`)).toBe('

앞뒤

'); + }); + + it('지운 그림 뒤의 형제 태그는 제 이름으로 닫힌다', () => { + expect( + clean( + `

굵게기울임

`, + options, + ), + ).toBe( + `

굵게기울임

`, + ); + }); + + it('한 번 더 정화해도 그대로이고, 받은 메일 정화를 거쳐도 그림이 남는다 (임시 저장본을 다시 읽을 때)', () => { + const once = clean( + `

서명

`, + options, + ); + + expect(clean(once, options)).toBe(once); + // 받은 메일 정화는 서명 그림 주소(상대 주소)를 외부 이미지로 보지 않고 지운다 — 우리 주소는 cid 로 실려 나간다 + expect(sanitizeMailHtml(once)).toEqual({ + html: '

서명

', + 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(`

앞뒤

`, mine)).toBe( + kept ? `

앞뒤

` : '

앞뒤

', + ); + return; + } + // 폭·설명은 그 속성만 — 남으면 폭은 앞뒤 공백을 뗀 값, 설명은 그대로 + const left = attribute === 'width' ? value.trim() : value; + expect( + clean(``, mine), + ).toBe( + kept + ? `` + : ``, + ); + }, + ); + }); + }); + /** * 실제 편집기가 보내는 모양 — 브라우저가 style 을 정규화해 쓴다(값 끝의 `;`·쉼표 뒤 공백·따옴표 없는 한글 글꼴 이름). * 보내는 정화와 받은 메일 정화를 모두 지나도 남아야 한다. diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.ts index bf9d9fa..efca5c3 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.ts @@ -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-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로) + * - 그림은 **보내는 사람 자신의 서명 그림만** — `` 이고 그 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 = { @@ -72,8 +78,21 @@ const ALLOWED_ATTRIBUTES: Record = { 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; +} + // ── 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, +): 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 = 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(); } diff --git a/backend/src/modules/mail/html/signature-images.spec.ts b/backend/src/modules/mail/html/signature-images.spec.ts new file mode 100644 index 0000000..6e2605e --- /dev/null +++ b/backend/src/modules/mail/html/signature-images.spec.ts @@ -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 = + `

a

` + + `` + + ``; + + expect(findSignatureImageIds(html)).toEqual([OTHER, ID]); + expect(findSignatureImageIds('

그림 없음

')).toEqual([]); + }); + + it('주소를 바꾸고(cid 등) 나머지 속성은 그대로 둔다 — 실을 수 없는 그림은 태그째 지운다', () => { + const html = + `

서명

로고` + + ``; + + expect( + rewriteSignatureImages(html, (id) => + id === ID ? `cid:sig-${id}@arasystem.test` : null, + ), + ).toBe( + `

서명

로고`, + ); + }); +}); diff --git a/backend/src/modules/mail/html/signature-images.ts b/backend/src/modules/mail/html/signature-images.ts new file mode 100644 index 0000000..8f502b0 --- /dev/null +++ b/backend/src/modules/mail/html/signature-images.ts @@ -0,0 +1,69 @@ +/** + * 서명 그림 주소 규칙 (순수 함수) — 계약 2절 "서식 본문"의 그림 예외 · "서명". + * + * 서식 본문에 남는 그림은 보내는 사람 자신의 서명 그림 `` 뿐이다. + * - 임시 저장본은 이 주소 그대로 둔다 (편집기가 그 주소로 그림을 다시 불러온다) + * - 보낼 때는 서버가 그림을 메일 안에 함께 실어(`multipart/related`) 주소를 `cid:` 로 바꾼다 — 받는 쪽에서 외부 이미지처럼 막히지 않게 + * + * ⚠️ 아래 정규식은 **보내는 정화를 거친 HTML** 에만 쓴다 — 정화가 img 를 늘 `…` 순서로 다시 쓴다 + * (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( + `]*)>`, + '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(); + 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 ? '' : ``; + }, + ); +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 5c1251f..a0a0f20 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -7,8 +7,11 @@ import { Readable } from 'stream'; import { BusinessException } from '@/common/errors/business.exception'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; import type { MailSettings } from '@/modules/mail/mail.settings'; -import type { OutgoingMessage } from '@/modules/mail/mail.types'; -import { JMAP_SEND_TIMEOUT_MS } from './jmap.client'; +import type { DraftContent, OutgoingMessage } from '@/modules/mail/mail.types'; +import { + JMAP_INLINE_UPLOAD_TIMEOUT_MS, + JMAP_SEND_TIMEOUT_MS, +} from './jmap.client'; import type { JmapClient, JmapSession } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; @@ -390,7 +393,8 @@ describe('JmapMailProvider', () => { expect(argsOf(recorded[0], 1)).toEqual({ accountId: 'd', - filter: { inMailbox: 'a' }, + // 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 (계약 2절 "임시 저장") + filter: { inMailbox: 'a', notKeyword: '$ara-sending' }, sort: [{ property: 'receivedAt', isAscending: false }], position: 0, limit: 2, @@ -414,7 +418,16 @@ describe('JmapMailProvider', () => { summaryEmail('m1', '2026-10-26T05:12:00Z', { keywords: { $flagged: true }, subject: null, - hasAttachment: true, + // 엔진의 hasAttachment 는 보지 않는다 — 첨부 부분으로 정한다 + hasAttachment: false, + attachments: [ + { + type: 'application/pdf', + disposition: 'attachment', + cid: null, + size: 2048, + }, + ], }), ]), ]); @@ -439,6 +452,61 @@ describe('JmapMailProvider', () => { }); }); + it('첨부 표시는 엔진의 hasAttachment 대신 첨부 부분으로 정한다 — 본문 그림(서명 로고 등)만 있으면 false', async () => { + const signature = { + type: 'image/png', + disposition: 'inline', + cid: 'sig-3f1c0a2e@arasystem.test', + size: 18_342, + }; + const { provider, recorded } = fakeClient([ + queryResponses(['m1', 'm2', 'm3'], 0, 3, [ + // 엔진은 본문 그림도 첨부로 센다 (hasAttachment true) + summaryEmail('m1', '2026-10-26T09:00:00Z', { + hasAttachment: true, + attachments: [signature], + }), + summaryEmail('m2', '2026-10-26T08:00:00Z', { + hasAttachment: true, + attachments: [ + signature, + { + type: 'application/pdf', + disposition: 'attachment', + cid: null, + size: 20_480, + }, + ], + }), + summaryEmail('m3', '2026-10-26T07:00:00Z', { attachments: [] }), + ]), + ]); + + const page = await provider.listMessages(ARA, { + mailboxId: 'a', + limit: 20, + }); + + expect(page.items.map((item) => [item.id, item.hasAttachment])).toEqual([ + ['m1', false], + ['m2', true], + ['m3', false], + ]); + // 부분마다 판정에 쓰는 네 가지만 읽는다 (본문·이름·blob 은 읽지 않는다) + const get = argsOf(recorded[0], 2) as { + properties: string[]; + bodyProperties: string[]; + }; + expect(get.properties).toContain('attachments'); + expect(get.properties).not.toContain('hasAttachment'); + expect(get.bodyProperties).toEqual([ + 'type', + 'disposition', + 'cid', + 'size', + ]); + }); + it('마지막 페이지면 hasNext=false, nextCursor=null', async () => { const { provider } = fakeClient([queryResponses(['m5'], 4, 5)]); @@ -590,6 +658,10 @@ describe('JmapMailProvider', () => { }); expect(recorded[0].calls.map(([name]) => name)).toEqual(['Email/get']); expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + // 겹치는 속성 이름 없이 (attachments 는 요약·상세 둘 다에 있다) + const properties = (args as { properties: string[] }).properties; + expect(new Set(properties).size).toBe(properties.length); + expect(properties).not.toContain('hasAttachment'); }); it('상세 필드·첨부·내장 이미지 후보를 매핑한다', async () => { @@ -629,11 +701,15 @@ describe('JmapMailProvider', () => { expect(detail.inlineParts).toEqual([ { attachmentId: 'blob-logo', + // attachments 의 두 번째 — 본문에 넣으면 그 첨부의 isInline 을 켠다 + attachmentIndex: 1, cid: 'logo@x', type: 'image/png', size: 512, }, ]); + // 첨부 표시는 부분으로 — 엑셀(첨부)이 있어 true + expect(detail.hasAttachment).toBe(true); }); it('HTML 부분이 없으면 rawHtmlBody 는 null 이다 (JMAP htmlBody 가 text/plain 으로 채워지는 경우)', async () => { @@ -780,6 +856,10 @@ describe('JmapMailProvider', () => { }, }); expect(summary.mailboxIds).toEqual(['b']); + // 바꾼 뒤의 요약도 목록과 같은 방법으로 첨부 표시를 정한다 + expect(argsOf(recorded[0], 1)).toMatchObject({ + bodyProperties: ['type', 'disposition', 'cid', 'size'], + }); }); it('없는 메일은 RES_001, 잘못된 값은 VAL_001 이다', async () => { @@ -822,10 +902,28 @@ describe('JmapMailProvider', () => { }); describe('삭제 (deleteMessage)', () => { + /** 메일 + 메일함 확인 — 그때의 Email 상태 's1' */ const lookup = (mailboxIds: Record): JmapInvocation[] => [ - ['Email/get', { list: [{ id: 'm1', mailboxIds }] }, 'g'], + ['Email/get', { state: 's1', list: [{ id: 'm1', mailboxIds }] }, 'g'], ['Mailbox/get', { list: stalwartMailboxes }, 'm'], ]; + /** 지우기 전에 다시 확인한 메일 — 그때의 상태 */ + const recheck = ( + mailboxIds: Record | null, + state: string, + ): JmapInvocation[] => [ + [ + 'Email/get', + { state, list: mailboxIds ? [{ id: 'm1', mailboxIds }] : [] }, + 'g', + ], + ]; + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 'd'], + ]; + const destroyed: JmapInvocation[] = [ + ['Email/set', { destroyed: ['m1'] }, 'd'], + ]; it('휴지통이 아니면 휴지통으로 옮긴다', async () => { const { provider, recorded } = fakeClient([ @@ -841,15 +939,116 @@ describe('JmapMailProvider', () => { }); }); - it('휴지통에 있으면 영구 삭제한다', async () => { + it('휴지통에만 있으면 확인한 상태 그대로일 때만 영구 삭제한다 (ifInState)', async () => { const { provider, recorded } = fakeClient([ lookup({ b: true }), - [['Email/set', { destroyed: ['m1'] }, 'd']], + destroyed, ]); await provider.deleteMessage(ARA, 'm1'); - expect(argsOf(recorded[1])).toEqual({ accountId: 'd', destroy: ['m1'] }); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['m1'], + }); + expect(recorded).toHaveLength(2); + }); + + it('휴지통과 다른 메일함에 함께 있으면 지우지 않고 휴지통에서만 뺀다 — 다른 메일함의 사본은 남는다', async () => { + const { provider, recorded } = fakeClient([ + lookup({ b: true, a: true }), + [['Email/set', { updated: { m1: null } }, 'd']], + ]); + + await provider.deleteMessage(ARA, 'm1'); + + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { m1: { 'mailboxIds/b': null } }, + }); + }); + + it('어긋나면 다시 확인하고 그 상태로 다시 지운다', async () => { + const { provider, recorded } = fakeClient([ + lookup({ b: true }), + mismatch, + recheck({ b: true }, 's2'), + destroyed, + ]); + + await provider.deleteMessage(ARA, 'm1'); + + expect(argsOf(recorded[3])).toEqual({ + accountId: 'd', + ifInState: 's2', + destroy: ['m1'], + }); + }); + + it('확인하는 사이 휴지통 밖으로 되돌려졌거나 이미 없으면 아무것도 하지 않는다 — 지금 상태를 존중한다 (204 그대로)', async () => { + for (const restored of [{ a: true }, null]) { + const { provider, recorded } = fakeClient([ + lookup({ b: true }), + mismatch, + recheck(restored, 's2'), + ]); + + await expect(provider.deleteMessage(ARA, 'm1')).resolves.toBe( + undefined, + ); + expect(recorded).toHaveLength(3); + } + }); + + it('다시 보니 다른 메일함에도 들어 있으면 휴지통에서만 뺀다', async () => { + const { provider, recorded } = fakeClient([ + lookup({ b: true }), + mismatch, + recheck({ b: true, a: true }, 's2'), + [['Email/set', { updated: { m1: null } }, 'd']], + ]); + + await provider.deleteMessage(ARA, 'm1'); + + expect(argsOf(recorded[3])).toEqual({ + accountId: 'd', + ifInState: 's2', + update: { m1: { 'mailboxIds/b': null } }, + }); + }); + + it('어긋남이 세 번 이어지면 지우지 않고 MAIL_001 — 다시 시도하면 된다', async () => { + const { provider, recorded } = fakeClient([ + lookup({ b: true }), + mismatch, + recheck({ b: true }, 's2'), + mismatch, + recheck({ b: true }, 's3'), + mismatch, + ]); + + expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe('MAIL_001'); + expect(recorded).toHaveLength(6); + }); + + it('지우는 사이 없어졌으면(notFound) 이미 처리된 것이다 · 그 밖의 거절은 그 코드', async () => { + const raced = fakeClient([ + lookup({ b: true }), + [['Email/set', { notDestroyed: { m1: { type: 'notFound' } } }, 'd']], + ]); + await expect(raced.provider.deleteMessage(ARA, 'm1')).resolves.toBe( + undefined, + ); + + const refused = fakeClient([ + lookup({ b: true }), + [['Email/set', { notDestroyed: { m1: { type: 'forbidden' } } }, 'd']], + ]); + expect(await codeOf(refused.provider.deleteMessage(ARA, 'm1'))).toBe( + 'AUTH_003', + ); }); it('없는 메일은 RES_001 이다', async () => { @@ -930,7 +1129,8 @@ describe('JmapMailProvider', () => { .draft; expect(draft).toEqual({ mailboxIds: { d: true }, - keywords: { $draft: true, $seen: true }, + // 발송 중 표시 — 사용자의 임시 저장본 목록에 보이지 않게 (보낸편지함으로 옮길 때 뗀다) + keywords: { $draft: true, $seen: true, '$ara-sending': true }, from: [{ name: '김아라', email: ARA }], to: [{ name: '이도윤', email: DOYUN }], bcc: [{ name: null, email: 'seojun.park@arasystem.test' }], @@ -956,6 +1156,7 @@ describe('JmapMailProvider', () => { 'mailboxIds/d': null, 'mailboxIds/e': true, 'keywords/$draft': null, + 'keywords/$ara-sending': null, }, }, }); @@ -1233,6 +1434,207 @@ describe('JmapMailProvider', () => { expect(draft).not.toHaveProperty('inReplyTo'); expect(warn).toHaveBeenCalled(); }); + + describe('서명 그림 (inlineImages — multipart/related)', () => { + const png = Buffer.from('89504e470d0a1a0a', 'hex'); + const html = + '

김아라 드림

'; + const signatureImage = { + cid: 'sig-1@arasystem.test', + type: 'image/png', + name: 'signature-1.png', + data: png, + }; + const withImage: OutgoingMessage = { + ...outgoing, + htmlBody: html, + inlineImages: [signatureImage], + }; + + // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다 + afterEach(() => jest.restoreAllMocks()); + const alternative = { + type: 'multipart/alternative', + subParts: [ + { partId: 'body', type: 'text/plain' }, + { partId: 'html', type: 'text/html' }, + ], + }; + const inlinePart = { + blobId: 'blob-sig', + type: 'image/png', + name: 'signature-1.png', + cid: 'sig-1@arasystem.test', + disposition: 'inline', + }; + + it('그림을 엔진에 올리고 mixed[related[alternative[text, html], 그림], 첨부] 로 만든다', async () => { + const { provider, recorded, client } = fakeClient([ + prepared, + fits, + sent, + ]); + client.upload.mockResolvedValue({ + blobId: 'blob-sig', + type: 'image/png', + size: png.length, + }); + + await provider.sendMessage(ARA, withImage); + + expect(client.upload).toHaveBeenCalledTimes(1); + const [who, blob, type] = client.upload.mock.calls[0] as [ + string, + Blob, + string, + ]; + expect(who).toBe(ARA); + expect(type).toBe('image/png'); + expect(blob.type).toBe('image/png'); + expect(Buffer.from(await blob.arrayBuffer())).toEqual(png); + + const draft = ( + argsOf(recorded[2], 0).create as Record< + string, + Record + > + ).draft; + // 구조를 직접 적을 때는 textBody·htmlBody·attachments 를 함께 쓰지 않는다 (RFC 8621 §4.6) + expect(draft).not.toHaveProperty('textBody'); + expect(draft).not.toHaveProperty('htmlBody'); + expect(draft).not.toHaveProperty('attachments'); + expect(draft.bodyValues).toEqual({ + body: { value: '도윤님, 확인했습니다.' }, + html: { value: html }, + }); + expect(draft.bodyStructure).toEqual({ + type: 'multipart/mixed', + subParts: [ + { + type: 'multipart/related', + subParts: [alternative, inlinePart], + }, + { + blobId: 'blob-xlsx', + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + name: '체크리스트.xlsx', + disposition: 'attachment', + }, + ], + }); + }); + + it('첨부가 없으면 related 가 맨 위다 — 그림은 첨부 합계에 넣지 않는다 (Blob/get 없음)', async () => { + const { provider, recorded, client } = fakeClient([prepared, sent]); + client.upload.mockResolvedValue({ + blobId: 'blob-sig', + type: 'image/png', + size: png.length, + }); + + await provider.sendMessage(ARA, { ...withImage, attachments: [] }); + + expect(recorded).toHaveLength(2); + const draft = ( + argsOf(recorded[1], 0).create as Record< + string, + Record + > + ).draft; + expect(draft.bodyStructure).toEqual({ + type: 'multipart/related', + subParts: [alternative, inlinePart], + }); + }); + + it('서명 그림은 한꺼번에, 짧은 제한 시간으로 올린다 — 일반 업로드의 120초를 쓰지 않는다', async () => { + const { provider, client } = fakeClient([prepared, fits, sent]); + let inFlight = 0; + let maxInFlight = 0; + client.upload.mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setImmediate(resolve)); + inFlight -= 1; + return { blobId: `blob-${inFlight}`, type: 'image/png', size: 8 }; + }); + + await provider.sendMessage(ARA, { + ...withImage, + inlineImages: [ + signatureImage, + { ...signatureImage, cid: 'sig-2@arasystem.test' }, + { ...signatureImage, cid: 'sig-3@arasystem.test' }, + ], + }); + + expect(maxInFlight).toBe(3); + const options = (client.upload.mock.calls as unknown[][]).map( + (call) => call[3], + ); + expect(options).toEqual([ + { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS }, + { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS }, + { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS }, + ]); + }); + + it('발송 전체 시간(55초) 안에서 남은 만큼만 기다린다 — 보내기에 10초도 남지 않으면 보내지 않고 MAIL_001', async () => { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const { provider, recorded, client } = fakeClient([prepared, fits]); + client.upload.mockImplementation(() => { + // 그림을 올리는 데 50초가 걸렸다 — 보내기에 남은 시간은 5초 + now += 50_000; + return Promise.resolve({ + blobId: 'blob-sig', + type: 'image/png', + size: 8, + }); + }); + + expect(await codeOf(provider.sendMessage(ARA, withImage))).toBe( + 'MAIL_001', + ); + // 초안을 만들지도(보내지도) 않았다 — 같은 키로 다시 보내면 된다 + expect(recorded).toHaveLength(2); + }); + + it('앞 단계가 늦었으면 보내기 제한 시간도 남은 시간으로 줄인다', async () => { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const { provider, recorded, client } = fakeClient([ + prepared, + fits, + sent, + ]); + client.upload.mockImplementation(() => { + now += 25_000; + return Promise.resolve({ + blobId: 'blob-sig', + type: 'image/png', + size: 8, + }); + }); + + await provider.sendMessage(ARA, withImage); + + // 55초 - 25초 = 30초 (기본 40초보다 짧다) + expect(recorded[2].options).toMatchObject({ timeoutMs: 30_000 }); + }); + + it('그림을 올리지 못하면 메일을 만들지 않는다', async () => { + const { provider, recorded, client } = fakeClient([prepared, fits]); + client.upload.mockRejectedValue( + new BusinessException('MAIL_001', { context: { reason: 'upload' } }), + ); + + expect(await codeOf(provider.sendMessage(ARA, withImage))).toBe( + 'MAIL_001', + ); + expect(recorded).toHaveLength(2); + }); + }); }); describe('이미 보낸 메일 찾기 (findSentMessage — 중복 발송 방지)', () => { @@ -1354,6 +1756,948 @@ describe('JmapMailProvider', () => { }); }); + /** 계약 2절 "임시 저장" — 사용자의 임시 저장본 */ + describe('임시 저장 (saveDraft)', () => { + const XLSX = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + const content: DraftContent = { + to: [], + cc: [{ name: '이도윤', email: DOYUN }], + bcc: [], + subject: '쓰는 중', + body: '본문', + htmlBody: '

본문

', + attachments: [{ id: 'blob-xlsx', name: '체크리스트.xlsx', type: XLSX }], + }; + + const prepared = (...extra: JmapInvocation[]): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Identity/get', + { list: [{ id: 'b', name: '김아라', email: ARA }] }, + 'i', + ], + ...extra, + ]; + /** 바꿔 저장할 옛 초안 확인 — 준비와 따로 묻는다 (그때의 Email 상태 's1') */ + const oldCheck = (list: Record[]): JmapInvocation[] => [ + [ + 'Email/get', + { state: 's1', list, notFound: list.length > 0 ? [] : ['old1'] }, + 'old', + ], + ]; + const userDraft = (extra: Record = {}) => ({ + id: 'old1', + mailboxIds: { d: true }, + keywords: { $draft: true, $seen: true }, + ...extra, + }); + const fits: JmapInvocation[] = [ + [ + 'Blob/get', + { list: [{ id: 'blob-xlsx', size: 1000 }], notFound: [] }, + 'b', + ], + ]; + /** 만들기 — 응답의 상태('s9')는 지우기 조건으로 쓰지 않는다 */ + const created: JmapInvocation[] = [ + [ + 'Email/set', + { oldState: 's1', newState: 's9', created: { draft: { id: 'new1' } } }, + 'c', + ], + ]; + const readBack: JmapInvocation[] = [ + [ + 'Email/get', + { + list: [ + { + id: 'new1', + receivedAt: '2026-10-27T01:00:00Z', + attachments: [ + { + partId: '3', + blobId: 'blob-new', + name: '체크리스트.xlsx', + type: XLSX, + size: 1000, + disposition: 'attachment', + }, + ], + }, + ], + }, + 'g', + ], + ]; + const destroyed = (id: string): JmapInvocation[] => [ + ['Email/set', { destroyed: [id] }, 'x'], + ]; + const notDestroyed = (id: string, type: string): JmapInvocation[] => [ + ['Email/set', { notDestroyed: { [id]: { type } } }, 'x'], + ]; + /** 지우기가 거절됐다 — 확인한 뒤 계정의 메일이 바뀌었다 (ifInState) */ + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 'x'], + ]; + /** 지우기 전에 다시 확인한 옛 초안 — 그때의 상태 */ + const recheck = ( + list: Record[], + state: string, + ): JmapInvocation[] => [ + [ + 'Email/get', + { state, list, notFound: list.length > 0 ? [] : ['old1'] }, + 'g', + ], + ]; + const conditionalDestroy = (id: string, ifInState: string) => ({ + accountId: 'd', + ifInState, + destroy: [id], + }); + const saved = { + id: 'new1', + attachments: [ + { id: 'blob-new', name: '체크리스트.xlsx', type: XLSX, size: 1000 }, + ], + updatedAt: '2026-10-27T01:00:00.000Z', + }; + + const silenceWarnings = (provider: JmapMailProvider) => + jest + .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn') + .mockImplementation(() => undefined); + + it('처음 저장 — 임시보관함에 $draft 로 만들고, 다시 읽어 새 첨부 id 와 저장 시각을 돌려준다 (중복 방지 헤더·발송 표시 없음)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + fits, + created, + readBack, + ]); + + await expect(provider.saveDraft(ARA, content)).resolves.toEqual(saved); + + const draft = (argsOf(recorded[2]).create as Record) + .draft; + // 받는 사람이 없으면 to 를 쓰지 않는다 — 발송 지문·Message-ID·발송 중 표시도 없다 + expect(draft).toEqual({ + mailboxIds: { d: true }, + keywords: { $draft: true, $seen: true }, + from: [{ name: '김아라', email: ARA }], + cc: [{ name: '이도윤', email: DOYUN }], + subject: '쓰는 중', + bodyValues: { + body: { value: '본문' }, + html: { value: '

본문

' }, + }, + textBody: [{ partId: 'body', type: 'text/plain' }], + htmlBody: [{ partId: 'html', type: 'text/html' }], + attachments: [ + { + blobId: 'blob-xlsx', + type: XLSX, + name: '체크리스트.xlsx', + disposition: 'attachment', + }, + ], + }); + // 저장은 첨부를 복사하는 큰 요청이다 + expect(recorded[2].options).toMatchObject({ + timeoutMs: JMAP_SEND_TIMEOUT_MS, + }); + expect(argsOf(recorded[3])).toMatchObject({ + ids: ['new1'], + properties: ['id', 'receivedAt', 'attachments'], + }); + // 바꾸는 것이 아니면 아무것도 지우지 않는다 + expect(recorded).toHaveLength(4); + }); + + it('답장 초안이면 스레드 헤더와 답장 대상 id 를 남긴다 — 원 메일이 없으면 둘 다 없다', async () => { + const original: JmapInvocation = [ + 'Email/get', + { + list: [ + { + id: 'orig', + messageId: ['orig@arasystem.test'], + references: ['root@arasystem.test'], + }, + ], + }, + 'r', + ]; + const reply = { ...content, attachments: [], inReplyTo: 'orig' }; + const first = fakeClient([prepared(original), created, readBack]); + + await first.provider.saveDraft(ARA, reply); + + const draft = ( + argsOf(first.recorded[1]).create as Record< + string, + Record + > + ).draft; + expect(draft).toMatchObject({ + inReplyTo: ['orig@arasystem.test'], + references: ['root@arasystem.test', 'orig@arasystem.test'], + 'header:X-Ara-Reply-To-Id:asText': 'orig', + }); + + const gone = fakeClient([ + prepared(['Email/get', { list: [], notFound: ['orig'] }, 'r']), + created, + readBack, + ]); + await gone.provider.saveDraft(ARA, reply); + const orphan = ( + argsOf(gone.recorded[1]).create as Record< + string, + Record + > + ).draft; + expect(orphan).not.toHaveProperty('inReplyTo'); + expect(orphan).not.toHaveProperty('header:X-Ara-Reply-To-Id:asText'); + }); + + it('바꿔 저장 — 옛것을 따로 확인하고, 새것을 만들어 다시 읽은 뒤, 지우기 직전에 다시 확인한 상태로 옛것을 지운다', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + destroyed('old1'), + ]); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + + expect(argsOf(recorded[1])).toMatchObject({ + ids: ['old1'], + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'header:X-Ara-Payload-Hash:asText', + ], + }); + expect(recorded[3].calls.map(([name]) => name)).toEqual(['Email/set']); + expect(argsOf(recorded[3])).not.toHaveProperty('destroy'); + // 만들기 응답의 newState('s9')는 조건으로 쓰지 않는다 — 그 사이 다른 요청의 변경이 끼었을 수 있다 (RFC 8620 은 보장하지 않는다) + expect(argsOf(recorded[5])).toMatchObject({ + ids: ['old1'], + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'header:X-Ara-Payload-Hash:asText', + ], + }); + expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2')); + expect(recorded).toHaveLength(7); + }); + + it('옛 초안 확인은 준비 요청과 따로 묻는다 — 엔진이 그 id 를 거부하면 VAL_001 이 아니라 RES_001 (클라이언트는 새로 POST 한다)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + destroyed('old1'), + ]); + + await provider.saveDraft(ARA, content, 'old1'); + + expect(recorded[0].calls.map(([name]) => name)).toEqual([ + 'Mailbox/get', + 'Identity/get', + ]); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' }); + expect(recorded[1].calls.map(([name]) => name)).toEqual(['Email/get']); + expect(recorded[1].options).toEqual({ invalidRequestCode: 'RES_001' }); + }); + + it('만드는 사이 옛것이 휴지통으로 옮겨졌으면 지우지 않는다 — 새 저장본은 그대로, 저장은 성공 (경고)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + // 큰 첨부를 붙여 만드는 동안 다른 탭이 옛것을 휴지통으로 옮겼다 — 그 이동이 newState 에 들어 있다 + created, + readBack, + recheck([userDraft({ mailboxIds: { b: true } })], 's2'), + ]); + const warn = silenceWarnings(provider); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + // 지우기 요청을 보내지 않았다 (휴지통의 메일을 영구 삭제하지 않는다) + expect(recorded).toHaveLength(6); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '옛 임시 저장본이 그사이 다른 곳으로 옮겨져 지우지 않았습니다', + ), + ); + }); + + it('지우기가 어긋나면(stateMismatch) 다시 확인하고, 여전히 사용자 초안이면 그 상태로 다시 지운다', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + mismatch, + recheck([userDraft()], 's3'), + destroyed('old1'), + ]); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2')); + expect(argsOf(recorded[8])).toEqual(conditionalDestroy('old1', 's3')); + expect(recorded).toHaveLength(9); + }); + + it('어긋난 뒤 다시 보니 옛것이 없어졌으면 방금 만든 것을 지우고 409 MAIL_008', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + mismatch, + recheck([], 's3'), + destroyed('new1'), + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_008', + ); + // 방금 만든 것은 이 요청이 만든 초안이라 조건 없이 지운다 + expect(argsOf(recorded[8])).toEqual({ + accountId: 'd', + destroy: ['new1'], + }); + }); + + it('어긋난 뒤 다시 보니 옛것이 휴지통으로 옮겨졌으면 지우지 않는다 — 저장은 성공 (경고)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + mismatch, + recheck([userDraft({ mailboxIds: { b: true } })], 's3'), + ]); + const warn = silenceWarnings(provider); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + expect(recorded).toHaveLength(8); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '옛 임시 저장본이 그사이 다른 곳으로 옮겨져 지우지 않았습니다', + ), + ); + }); + + it('지우기가 세 번 모두 어긋나면 더 지우지 않는다 — 저장은 성공 (경고, 두 통이 남는다)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + mismatch, + recheck([userDraft()], 's3'), + mismatch, + recheck([userDraft()], 's4'), + mismatch, + ]); + const warn = silenceWarnings(provider); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + const destroys = recorded.filter( + (record) => + record.calls[0][0] === 'Email/set' && 'destroy' in argsOf(record), + ); + expect(destroys.map((record) => argsOf(record).ifInState)).toEqual([ + 's2', + 's3', + 's4', + ]); + expect(recorded).toHaveLength(11); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '확인하는 사이 메일함이 계속 바뀌어 옛 임시 저장본을 지우지 않았습니다', + ), + ); + }); + + it('옛 초안이 이미 없으면 아무것도 만들지 않고 409 MAIL_008', async () => { + const { provider, recorded } = fakeClient([prepared(), oldCheck([])]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_008', + ); + expect(recorded).toHaveLength(2); + }); + + it('임시보관함의 사용자 초안이 아니면 아무것도 지우지 않고 404 RES_001 — 휴지통·보낸 메일·발송 중인 내부 초안·다른 메일함에도 있는 초안', async () => { + for (const email of [ + userDraft({ mailboxIds: { b: true } }), + // 임시보관함과 받은편지함에 함께 — 지우면 받은편지함의 사본까지 사라진다 + userDraft({ mailboxIds: { d: true, a: true } }), + userDraft({ keywords: { $seen: true } }), + userDraft({ + keywords: { $draft: true, $seen: true, '$ara-sending': true }, + }), + userDraft({ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface' }), + ]) { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([email]), + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'RES_001', + ); + expect(recorded).toHaveLength(2); + } + }); + + it('새것을 만들지 못하면 옛것을 지우지 않는다 — 용량 초과 MAIL_003 · 없는 첨부 MAIL_004', async () => { + const cases: [Record, string][] = [ + [{ type: 'overQuota' }, 'MAIL_003'], + [ + { type: 'invalidProperties', properties: ['attachments/blobId'] }, + 'MAIL_004', + ], + ]; + for (const [error, code] of cases) { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + [['Email/set', { notCreated: { draft: error } }, 'c']], + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + code, + ); + expect(recorded).toHaveLength(4); + } + }); + + it('첨부 합계가 한도를 넘으면 만들지 않는다 (413 FILE_002)', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + [ + [ + 'Blob/get', + { list: [{ id: 'blob-xlsx', size: 26 * MB }], notFound: [] }, + 'b', + ], + ], + ]); + + expect(await codeOf(provider.saveDraft(ARA, content))).toBe('FILE_002'); + expect(recorded).toHaveLength(2); + }); + + it('옛것이 지우기 직전 확인 뒤 없어졌으면(notFound) 방금 만든 것을 지우고 409 MAIL_008', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + notDestroyed('old1', 'notFound'), + destroyed('new1'), + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_008', + ); + expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2')); + expect(argsOf(recorded[7])).toEqual({ + accountId: 'd', + destroy: ['new1'], + }); + }); + + it('지우기 직전 확인에서 옛것이 이미 없으면 방금 만든 것을 지우고 409 MAIL_008', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([], 's2'), + destroyed('new1'), + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_008', + ); + expect(argsOf(recorded[6])).toEqual({ + accountId: 'd', + destroy: ['new1'], + }); + expect(recorded).toHaveLength(7); + }); + + it('방금 만든 것을 지우지 못해도 MAIL_008 — 경고만 남긴다 (임시보관함에 한 통이 더 남는다)', async () => { + const { provider } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + notDestroyed('old1', 'notFound'), + notDestroyed('new1', 'forbidden'), + ]); + const warn = silenceWarnings(provider); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_008', + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('방금 만든 임시 저장본을 지우지 못했습니다'), + ); + }); + + it('옛것을 다른 이유로 지우지 못하면 저장은 성공이다 — 경고만 (두 통이 남을 수 있다)', async () => { + const { provider } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + readBack, + recheck([userDraft()], 's2'), + notDestroyed('old1', 'forbidden'), + ]); + const warn = silenceWarnings(provider); + + await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual( + saved, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('옛 임시 저장본을 지우지 못해'), + ); + }); + + it('만든 것을 다시 읽지 못하면(엔진 이상 — MAIL_001) 그것을 지우고, 옛것은 그대로 둔다', async () => { + const { provider, recorded } = fakeClient([ + prepared(), + oldCheck([userDraft()]), + fits, + created, + [['Email/get', { list: [], notFound: ['new1'] }, 'g']], + destroyed('new1'), + ]); + + expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe( + 'MAIL_001', + ); + expect(argsOf(recorded[5])).toEqual({ + accountId: 'd', + destroy: ['new1'], + }); + expect(recorded).toHaveLength(6); + }); + }); + + describe('이어 쓸 임시 저장본 (getDraft)', () => { + const draftEmail = (extra: Record = {}) => ({ + ...summaryEmail('dr1', '2026-10-27T01:00:00Z', { + mailboxIds: { d: true }, + keywords: { $draft: true, $seen: true }, + }), + cc: [], + bcc: [{ name: null, email: 'seojun.park@arasystem.test' }], + replyTo: [], + sentAt: null, + messageId: ['dr1@arasystem.test'], + references: null, + bodyValues: { + '1': { value: '텍스트 본문', isTruncated: false }, + '2': { value: '

서식 본문

', isTruncated: false }, + }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + ...extra, + }); + const read = (email: Record): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [email] }, 'g'], + ]; + + it('초안의 받는 사람·제목·본문 원문·첨부와 저장 시각을 돌려준다 (본문 값을 함께 읽는다)', async () => { + const { provider, recorded } = fakeClient([read(draftEmail())]); + + await expect(provider.getDraft(ARA, 'dr1')).resolves.toEqual({ + id: 'dr1', + to: [{ name: null, email: DOYUN }], + cc: [], + bcc: [{ name: null, email: 'seojun.park@arasystem.test' }], + subject: '제목 dr1', + textBody: '텍스트 본문', + rawHtmlBody: '

서식 본문

', + attachments: [], + inReplyTo: null, + updatedAt: '2026-10-27T01:00:00.000Z', + }); + expect(argsOf(recorded[0], 1)).toMatchObject({ + ids: ['dr1'], + fetchHTMLBodyValues: true, + fetchTextBodyValues: true, + maxBodyValueBytes: MAX_BODY_VALUE_BYTES, + }); + expect( + (argsOf(recorded[0], 1) as { properties: string[] }).properties, + ).toEqual( + expect.arrayContaining([ + 'keywords', + 'mailboxIds', + 'header:X-Ara-Payload-Hash:asText', + 'header:X-Ara-Reply-To-Id:asText', + ]), + ); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + }); + + it('답장 대상은 그 메일이 아직 있을 때만 돌려준다', async () => { + const replying = draftEmail({ + 'header:X-Ara-Reply-To-Id:asText': ' orig ', + }); + const found = fakeClient([ + read(replying), + [['Email/get', { list: [{ id: 'orig' }], notFound: [] }, 'r']], + ]); + await expect(found.provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({ + inReplyTo: 'orig', + }); + expect(argsOf(found.recorded[1])).toEqual({ + accountId: 'd', + ids: ['orig'], + properties: ['id'], + }); + + const gone = fakeClient([ + read(replying), + [['Email/get', { list: [], notFound: ['orig'] }, 'r']], + ]); + await expect(gone.provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({ + inReplyTo: null, + }); + + // 형식이 틀린 값은 엔진에 묻지 않는다 + const malformed = fakeClient([ + read(draftEmail({ 'header:X-Ara-Reply-To-Id:asText': '../x' })), + ]); + await expect( + malformed.provider.getDraft(ARA, 'dr1'), + ).resolves.toMatchObject({ inReplyTo: null }); + expect(malformed.recorded).toHaveLength(1); + }); + + it('임시보관함의 사용자 초안이 아니면 RES_001 — 보낸 메일·휴지통의 초안·다른 메일함에도 있는 초안·발송 중인 내부 초안', async () => { + for (const email of [ + draftEmail({ mailboxIds: { e: true }, keywords: { $seen: true } }), + draftEmail({ mailboxIds: { b: true } }), + // 임시보관함과 다른 메일함에 함께 있는 메일은 사용자 초안으로 보지 않는다 + draftEmail({ mailboxIds: { d: true, a: true } }), + draftEmail({ + keywords: { $draft: true, $seen: true, '$ara-sending': true }, + }), + draftEmail({ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface' }), + ]) { + const { provider } = fakeClient([read(email)]); + + expect(await codeOf(provider.getDraft(ARA, 'dr1'))).toBe('RES_001'); + } + }); + + it('없는 메일은 RES_001', async () => { + const { provider } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [], notFound: ['dr1'] }, 'g'], + ], + ]); + + expect(await codeOf(provider.getDraft(ARA, 'dr1'))).toBe('RES_001'); + }); + }); + + describe('보낸 초안 지우기 (deleteDraft)', () => { + /** 메일함 + 초안 확인 — 그때의 Email 상태 's1' */ + const lookup = (list: Record[]): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { state: 's1', list }, 'g'], + ]; + const draft = { + id: 'dr1', + mailboxIds: { d: true }, + keywords: { $draft: true }, + }; + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 'x'], + ]; + const recheck = ( + list: Record[], + state: string, + ): JmapInvocation[] => [['Email/get', { state, list }, 'g']]; + const destroyedDraft: JmapInvocation[] = [ + ['Email/set', { destroyed: ['dr1'] }, 'x'], + ]; + const silence = (provider: JmapMailProvider) => + jest + .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn') + .mockImplementation(() => undefined); + + it('사용자 초안이면 확인한 상태 그대로일 때만 지운다 (ifInState)', async () => { + const { provider, recorded } = fakeClient([ + lookup([draft]), + destroyedDraft, + ]); + + await provider.deleteDraft(ARA, 'dr1'); + + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['dr1'], + }); + }); + + it('어긋나면 다시 확인하고 그 상태로 다시 지운다 — 그사이 옮겨졌거나 없어졌으면 지우지 않는다', async () => { + const retried = fakeClient([ + lookup([draft]), + mismatch, + recheck([draft], 's2'), + destroyedDraft, + ]); + await retried.provider.deleteDraft(ARA, 'dr1'); + expect(argsOf(retried.recorded[3])).toEqual({ + accountId: 'd', + ifInState: 's2', + destroy: ['dr1'], + }); + + const gone = fakeClient([lookup([draft]), mismatch, recheck([], 's2')]); + await expect(gone.provider.deleteDraft(ARA, 'dr1')).resolves.toBe( + undefined, + ); + expect(gone.recorded).toHaveLength(3); + + const moved = fakeClient([ + lookup([draft]), + mismatch, + recheck([{ ...draft, mailboxIds: { b: true } }], 's2'), + ]); + const warn = silence(moved.provider); + await expect(moved.provider.deleteDraft(ARA, 'dr1')).resolves.toBe( + undefined, + ); + expect(moved.recorded).toHaveLength(3); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '그사이 다른 곳으로 옮겨진 메일이라 지우지 않습니다', + ), + ); + }); + + it('세 번 모두 어긋나면 포기한다 — 던지지 않고 경고만 (발송은 성공)', async () => { + const { provider, recorded } = fakeClient([ + lookup([draft]), + mismatch, + recheck([draft], 's2'), + mismatch, + recheck([draft], 's3'), + mismatch, + ]); + const warn = silence(provider); + + await expect(provider.deleteDraft(ARA, 'dr1')).resolves.toBe(undefined); + expect(recorded).toHaveLength(6); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching( + /^\[MAIL_001\].*확인하는 사이 메일함이 계속 바뀌어/, + ), + ); + }); + + it('다른 메일함에도 들어 있는 초안은 지우지 않는다 — 지우면 그 메일함의 사본까지 사라진다', async () => { + const { provider, recorded } = fakeClient([ + lookup([{ ...draft, mailboxIds: { d: true, a: true } }]), + ]); + const warn = silence(provider); + + await provider.deleteDraft(ARA, 'dr1'); + + expect(recorded).toHaveLength(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('임시 저장본이 아닌 메일이라 지우지 않습니다'), + ); + }); + + it('이미 없으면 아무것도 하지 않고, 초안이 아니면(보낸 메일 id 등) 지우지 않는다 — 경고만', async () => { + const gone = fakeClient([lookup([])]); + await expect(gone.provider.deleteDraft(ARA, 'dr1')).resolves.toBe( + undefined, + ); + expect(gone.recorded).toHaveLength(1); + + const sentMail = fakeClient([ + lookup([{ ...draft, mailboxIds: { e: true }, keywords: {} }]), + ]); + const warn = jest + .spyOn( + Reflect.get(sentMail.provider, 'logger') as { warn: () => void }, + 'warn', + ) + .mockImplementation(() => undefined); + await sentMail.provider.deleteDraft(ARA, 'sent1'); + expect(sentMail.recorded).toHaveLength(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('임시 저장본이 아닌 메일이라 지우지 않습니다'), + ); + }); + + it('지우는 사이 없어졌으면(notFound) 성공, 다른 거절은 실패를 던진다', async () => { + const raced = fakeClient([ + lookup([draft]), + [['Email/set', { notDestroyed: { dr1: { type: 'notFound' } } }, 'x']], + ]); + await expect(raced.provider.deleteDraft(ARA, 'dr1')).resolves.toBe( + undefined, + ); + + const refused = fakeClient([ + lookup([draft]), + [['Email/set', { notDestroyed: { dr1: { type: 'forbidden' } } }, 'x']], + ]); + expect(await codeOf(refused.provider.deleteDraft(ARA, 'dr1'))).toBe( + 'AUTH_003', + ); + }); + }); + + /** 계약 2절 "받는 사람 추천" — 그 직원 자신의 메일함에서만 */ + describe('주고받은 주소 (listCorrespondents)', () => { + const mailboxes: JmapInvocation[] = [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ]; + + it('보낸편지함 최근 메일의 받는 사람·참조·숨은참조와 받은편지함 최근 메일의 보낸 사람을 한 요청으로 모은다 (본문은 읽지 않는다)', async () => { + const { provider, recorded } = fakeClient([ + mailboxes, + [ + ['Email/query', { ids: ['s1'] }, 'sq'], + [ + 'Email/get', + { + list: [ + { + id: 's1', + receivedAt: '2026-10-26T05:00:00Z', + to: [{ name: '이도윤', email: DOYUN }], + cc: [{ name: null, email: 'seojun.park@arasystem.test' }], + bcc: null, + }, + ], + }, + 'sg', + ], + ['Email/query', { ids: ['r1'] }, 'rq'], + [ + 'Email/get', + { + list: [ + { + id: 'r1', + receivedAt: '2026-10-25T05:00:00+09:00', + from: [{ name: '고객', email: 'client@partner.test' }], + }, + ], + }, + 'rg', + ], + ], + ]); + + await expect( + provider.listCorrespondents(ARA, { sent: 500, received: 300 }), + ).resolves.toEqual([ + { name: '이도윤', email: DOYUN, at: '2026-10-26T05:00:00.000Z' }, + { + name: null, + email: 'seojun.park@arasystem.test', + at: '2026-10-26T05:00:00.000Z', + }, + { + name: '고객', + email: 'client@partner.test', + at: '2026-10-24T20:00:00.000Z', + }, + ]); + + expect(recorded).toHaveLength(2); + expect(argsOf(recorded[1], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: 'e' }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 500, + }); + expect(argsOf(recorded[1], 1)).toMatchObject({ + properties: ['to', 'cc', 'bcc', 'receivedAt'], + }); + expect(argsOf(recorded[1], 2)).toMatchObject({ + filter: { inMailbox: 'a' }, + limit: 300, + }); + expect(argsOf(recorded[1], 3)).toMatchObject({ + properties: ['from', 'receivedAt'], + }); + }); + + it('역할이 있는 메일함이 없으면 묻지 않는다', async () => { + const { provider, recorded } = fakeClient([ + [['Mailbox/get', { list: [stalwartMailboxes[0]] }, 'm']], + ]); + + await expect( + provider.listCorrespondents(ARA, { sent: 500, received: 500 }), + ).resolves.toEqual([]); + expect(recorded).toHaveLength(1); + }); + }); + describe('첨부 업로드 (uploadAttachment)', () => { let dir: string; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index cc5c9aa..41457ac 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -5,11 +5,17 @@ import { openAsBlob } from 'fs'; import { BusinessException } from '@/common/errors/business.exception'; import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy'; -import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; +import { + MAIL_ID_PATTERN, + MAX_BODY_VALUE_BYTES, +} from '@/modules/mail/mail.constants'; import type { MailProvider } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { AttachmentContent, + Correspondence, + DraftContent, + DraftSource, MailAccountProfile, MailAddress, MailAttachment, @@ -21,13 +27,16 @@ import type { MessageSummary, MessageUpdate, OutgoingAttachment, + OutgoingInlineImage, OutgoingMessage, + SavedDraft, SentMessageRecord, UploadedFile, } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { + JMAP_INLINE_UPLOAD_TIMEOUT_MS, JMAP_SEND_TIMEOUT_MS, JmapClient, type JmapSession, @@ -38,15 +47,18 @@ import { BODY_PART_PROPERTIES, DETAIL_PROPERTIES, MAILBOX_PROPERTIES, + SUMMARY_BODY_PART_PROPERTIES, SUMMARY_PROPERTIES, fallbackDisplayName, findAttachment, pickIdentity, selectAccountQuota, + toAddresses, toDetailSource, toMailbox, toSummary, toUsage, + toUtcIso, } from './jmap.mapper'; import type { JmapBlob, @@ -73,6 +85,73 @@ const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob'; const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash'; const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`; +/** + * 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면 + * 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 — + * 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). + * 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다 + * (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft). + */ +const INTERNAL_SEND_KEYWORD = '$ara-sending'; + +/** + * 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장"). + * 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다. + */ +const REPLY_TO_ID_PROPERTY = 'header:X-Ara-Reply-To-Id:asText'; + +/** 사용자 초안인지 볼 때 읽는 속성 */ +const DRAFT_CHECK_PROPERTIES = [ + 'id', + 'mailboxIds', + 'keywords', + FINGERPRINT_PROPERTY, +]; + +/** 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만) */ +const DRAFT_DETAIL_PROPERTIES = [ + ...new Set([ + ...DETAIL_PROPERTIES, + ...DRAFT_CHECK_PROPERTIES, + REPLY_TO_ID_PROPERTY, + ]), +]; + +/** 받는 사람 추천 — 주고받은 주소를 모을 때 읽는 속성 */ +const SENT_ADDRESS_PROPERTIES = ['to', 'cc', 'bcc', 'receivedAt']; +const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt']; + +/** + * 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐. + * 사용자 초안(교체 저장·보낸 뒤)과 휴지통 비우기(DELETE /api/messages/{id})가 함께 쓴다. + * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 — + * 그래도 이 횟수 안에 맞지 않으면 지우지 않는다. + */ +const CONDITIONAL_DESTROY_ATTEMPTS = 3; + +/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */ +const STATE_MISMATCH = 'stateMismatch'; + +/** + * 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 + * (백엔드가 먼저 답해야 사용자가 같은 키로 다시 보낼 수 있다 — jmap.client.ts 의 JMAP_SEND_TIMEOUT_MS 주석). + * 서명 그림 올리기와 보내기(초안 생성 + 제출)가 이 안에서 남은 시간만 쓴다. + */ +const SEND_TOTAL_BUDGET_MS = 55_000; +/** 보내기에 남은 시간이 이보다 적으면 보내기 시작하지 않는다 — 보내다 끊기면 나갔는지 알 수 없다 */ +const MIN_SEND_WINDOW_MS = 10_000; + +/** 사용자 초안을 조건부로 지운 결과 */ +type DraftDestroyOutcome = + /** 지웠다 */ + | 'destroyed' + /** 이미 없다 */ + | 'gone' + /** 그사이 사용자 초안이 아니게 됐다(다른 메일함으로 옮겨짐 등) — 지우지 않았다 */ + | 'not-a-draft' + /** 확인하는 사이 계정의 메일이 계속 바뀌었다 — 지우지 않았다 */ + | 'contended'; + /** * 중복 발송 확인 때 훑는 최근 메일 수 (보낸편지함 + 임시보관함, 받은 시각 내림차순). * 재시도는 같은 쓰기 화면에서 곧 오므로 충분하다 — 그 사이 이보다 많이 보냈다면 다시 보낸다. @@ -146,6 +225,99 @@ function roleMailboxId( return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id; } +/** + * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것. + * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다 + * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다 + */ +/** 메일이 들어 있는 메일함 id */ +function inMailboxes(email: JmapEmail): string[] { + return Object.entries(email.mailboxIds ?? {}) + .filter(([, isIn]) => isIn === true) + .map(([mailboxId]) => mailboxId); +} + +function isUserDraft(email: JmapEmail, draftsId: string): boolean { + const fingerprint = (email as unknown as Record)[ + FINGERPRINT_PROPERTY + ]; + const mailboxes = inMailboxes(email); + return ( + mailboxes.length === 1 && + mailboxes[0] === draftsId && + email.keywords?.$draft === true && + email.keywords?.[INTERNAL_SEND_KEYWORD] !== true && + !(typeof fingerprint === 'string' && fingerprint.trim() !== '') + ); +} + +/** 메일에 함께 싣는 그림 — 엔진에 올린 blob */ +interface UploadedInlineImage { + readonly blobId: string; + readonly cid: string; + readonly type: string; + readonly name: string; +} + +/** + * 본문·첨부 속성. 그림을 함께 실을 때만 bodyStructure 로 구조를 직접 적는다 — + * mixed[related[alternative[text, html], 그림…], 첨부…] (첨부가 없으면 related 가 맨 위). 그 밖에는 지금처럼 + * textBody·htmlBody·attachments 로 맡긴다 (엔진이 alternative·mixed 를 만든다, RFC 8621 §4.6). + * (2026-10-27 개발 엔진으로 이 구조를 만들어 다시 읽고, 우리 받은 메일 정화가 cid 그림을 보이는 것까지 확인) + */ +function bodyProperties( + message: Pick, + inline: readonly UploadedInlineImage[], +): Record { + const bodyValues = { + body: { value: message.body }, + ...(message.htmlBody ? { html: { value: message.htmlBody } } : {}), + }; + const attachments = message.attachments.map((attachment) => ({ + blobId: attachment.id, + type: attachment.type, + name: attachment.name, + disposition: 'attachment', + })); + + if (inline.length === 0 || !message.htmlBody) { + return { + bodyValues, + textBody: [{ partId: 'body', type: 'text/plain' }], + ...(message.htmlBody + ? { htmlBody: [{ partId: 'html', type: 'text/html' }] } + : {}), + ...(attachments.length ? { attachments } : {}), + }; + } + + const related = { + type: 'multipart/related', + subParts: [ + { + type: 'multipart/alternative', + subParts: [ + { partId: 'body', type: 'text/plain' }, + { partId: 'html', type: 'text/html' }, + ], + }, + ...inline.map((image) => ({ + blobId: image.blobId, + type: image.type, + name: image.name, + cid: image.cid, + disposition: 'inline', + })), + ], + }; + return { + bodyValues, + bodyStructure: attachments.length + ? { type: 'multipart/mixed', subParts: [related, ...attachments] } + : related, + }; +} + /** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */ export function buildThreadHeaders( original: Pick | undefined, @@ -366,7 +538,12 @@ export class JmapMailProvider implements MailProvider { ['Email/set', { accountId, update: { [id]: patch } }, 'u'], [ 'Email/get', - { accountId, ids: [id], properties: SUMMARY_PROPERTIES }, + { + accountId, + ids: [id], + properties: SUMMARY_PROPERTIES, + bodyProperties: SUMMARY_BODY_PART_PROPERTIES, + }, 'g', ], ], @@ -412,29 +589,128 @@ export class JmapMailProvider implements MailProvider { }); } - const inTrash = email.mailboxIds?.[trashId] === true; + if (email.mailboxIds?.[trashId] === true) { + // 휴지통에 있다 — 확인한 상태 그대로일 때만 영구 삭제한다 + const { state } = expectResult>( + lookup, + 'g', + 'Email/get', + ); + await this.purgeFromTrash( + user, + email.id, + trashId, + typeof state === 'string' + ? { state, mailboxIds: inMailboxes(email) } + : null, + ); + return; + } + const responses = await this.jmap.call(user, (accountId) => [ [ 'Email/set', - inTrash - ? { accountId, destroy: [email.id] } - : { - accountId, - update: { [email.id]: { mailboxIds: { [trashId]: true } } }, - }, + { + accountId, + update: { [email.id]: { mailboxIds: { [trashId]: true } } }, + }, 'd', ], ]); - const set = expectResult(responses, 'd', 'Email/set'); - const failure = firstSetError(inTrash ? set.notDestroyed : set.notUpdated); + const failure = firstSetError(set.notUpdated); if (failure) throw jmapFailure(failure, { method: 'Email/set' }); } + /** + * 휴지통의 메일을 영구 삭제한다 — 확인한 뒤 계정의 메일이 바뀌지 않았을 때만 (ifInState, destroyUserDraft 와 같은 모양). + * - 휴지통에만 있으면 지운다 (영구 삭제) + * - 휴지통과 다른 메일함에 함께 있으면 지우지 않고 휴지통에서만 뺀다 — 지우면 다른 메일함의 사본까지 사라진다 + * - 확인하는 사이 휴지통 밖으로 되돌려졌거나 이미 없으면 아무것도 하지 않는다 — 지금 상태를 존중한다 (응답은 그대로 204, "이미 처리됨") + * - 어긋남이 CONDITIONAL_DESTROY_ATTEMPTS 번 이어지면 지우지 않고 MAIL_001 (다시 시도하면 된다) + * @param checked 휴지통에 있음을 확인한 때의 상태와 그때의 메일함 — 모르면 null (먼저 확인한다) + */ + private async purgeFromTrash( + user: string, + id: string, + trashId: string, + checked: { state: string; mailboxIds: string[] } | null, + ): Promise { + let snapshot = checked; + for ( + let attempt = 0; + attempt < CONDITIONAL_DESTROY_ATTEMPTS; + attempt += 1 + ) { + if (snapshot === null) { + const lookup = await this.jmap.call(user, (accountId) => [ + [ + 'Email/get', + { accountId, ids: [id], properties: ['id', 'mailboxIds'] }, + 'g', + ], + ]); + const found = expectResult>( + lookup, + 'g', + 'Email/get', + ); + const email = found.list?.[0]; + // 이미 없다 · 휴지통 밖으로 되돌려졌다 — 아무것도 하지 않는다 + if (!email || email.mailboxIds?.[trashId] !== true) return; + if (typeof found.state !== 'string') { + throw new BusinessException('MAIL_001', { + context: { reason: 'trash-state-unknown' }, + }); + } + snapshot = { state: found.state, mailboxIds: inMailboxes(email) }; + } + + const onlyInTrash = snapshot.mailboxIds.every( + (mailboxId) => mailboxId === trashId, + ); + const ifInState = snapshot.state; + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Email/set', + onlyInTrash + ? { accountId, ifInState, destroy: [id] } + : { + accountId, + ifInState, + update: { [id]: { [`mailboxIds/${trashId}`]: null } }, + }, + 'd', + ], + ]); + const result = findResult(responses, 'd', 'Email/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) { + snapshot = null; + continue; + } + throw jmapFailure(result.error, { method: 'Email/set' }); + } + const set = result.args as JmapSetResult; + const failure = firstSetError( + onlyInTrash ? set.notDestroyed : set.notUpdated, + ); + // 지웠다 · 휴지통에서 뺐다 · 그사이 없어졌다 + if (!failure || failure.type === 'notFound') return; + throw jmapFailure(failure, { method: 'Email/set' }); + } + throw new BusinessException('MAIL_001', { + context: { reason: 'trash-delete-contended' }, + }); + } + async sendMessage( user: string, message: OutgoingMessage, ): Promise<{ id: string }> { + const startedAt = Date.now(); + const budgetLeft = () => SEND_TOTAL_BUDGET_MS - (Date.now() - startedAt); + // 1) 준비 — 메일함 역할, 발신 ID, (답장이면) 원 메일의 스레드 헤더 const session = await this.jmap.getSession(user); const { inReplyTo } = message; @@ -493,11 +769,26 @@ export class JmapMailProvider implements MailProvider { // 2) 첨부 합계 확인 — 엔진에 메일을 만들기 전에 (없는 첨부 MAIL_004, 한도 초과 FILE_002) await this.assertAttachmentsFit(user, message.attachments, session); + // 서명 그림은 메일 안에 함께 싣는다 — 엔진에 blob 으로 올린다 (한 장 300KB 이하, 첨부 합계에 넣지 않는다). + // 발송 요청 전체가 SEND_TOTAL_BUDGET_MS 안에 답하도록, 남은 시간 안에서 짧게 기다린다 + const inline = await this.uploadInlineImages( + user, + message.inlineImages, + Math.min(JMAP_INLINE_UPLOAD_TIMEOUT_MS, budgetLeft()), + ); + // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 아예 시작하지 않는다 (아무것도 나가지 않았으니 같은 키로 다시 보내면 된다) + const sendTimeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, budgetLeft()); + if (sendTimeoutMs < MIN_SEND_WINDOW_MS) { + throw new BusinessException('MAIL_001', { + context: { reason: 'send-budget-exhausted', leftMs: sendTimeoutMs }, + }); + } - // 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다 + // 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다. + // 발송 중 키워드로 사용자의 임시 저장본 목록에 보이지 않게 하고, 옮길 때 뗀다 const draft = { mailboxIds: { [draftsId]: true }, - keywords: { $draft: true, $seen: true }, + keywords: { $draft: true, $seen: true, [INTERNAL_SEND_KEYWORD]: true }, // 중복 발송 방지 — 같은 키의 재시도는 같은 Message-ID 로 이 메일을 다시 찾는다 ...(message.messageId ? { messageId: [message.messageId] } : {}), ...(message.fingerprint @@ -514,25 +805,9 @@ export class JmapMailProvider implements MailProvider { ...(message.bcc.length ? { bcc: message.bcc.map(toJmapAddress) } : {}), subject: message.subject, ...(thread ?? {}), - // 서식 본문이 있으면 textBody + htmlBody → 엔진이 multipart/alternative 로 만든다 (RFC 8621 §4.6) - bodyValues: { - body: { value: message.body }, - ...(message.htmlBody ? { html: { value: message.htmlBody } } : {}), - }, - textBody: [{ partId: 'body', type: 'text/plain' }], - ...(message.htmlBody - ? { htmlBody: [{ partId: 'html', type: 'text/html' }] } - : {}), - ...(message.attachments.length - ? { - attachments: message.attachments.map((attachment) => ({ - blobId: attachment.id, - type: attachment.type, - name: attachment.name, - disposition: 'attachment', - })), - } - : {}), + // 서식 본문이 있으면 textBody + htmlBody → 엔진이 multipart/alternative 로 만든다 (RFC 8621 §4.6). + // 서명 그림이 있으면 그 둘과 그림을 multipart/related 로 묶는다 + ...bodyProperties(message, inline), }; const responses = await this.jmap.call( @@ -549,14 +824,15 @@ export class JmapMailProvider implements MailProvider { [`mailboxIds/${draftsId}`]: null, [`mailboxIds/${sentId}`]: true, 'keywords/$draft': null, + [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, }, }, }, 's', ], ], - // 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다 - { invalidRequestCode: 'VAL_001', timeoutMs: JMAP_SEND_TIMEOUT_MS }, + // 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다 (발송 전체 시간 안에서) + { invalidRequestCode: 'VAL_001', timeoutMs: sendTimeoutMs }, ); const created = expectResult(responses, 'e', 'Email/set'); @@ -744,6 +1020,339 @@ export class JmapMailProvider implements MailProvider { return this.jmap.downloadBytes(user, attachmentId, maxBytes); } + /** + * 임시 저장 — 새 초안을 만들고(요청 1), 만든 초안을 다시 읽고(요청 2), 바꾸는 경우 옛 초안을 지운다(요청 3). + * 만들기와 지우기를 한 Email/set 에 넣지 않는다 — 엔진은 만들기가 실패해도 지우기를 그대로 해서(2026-10-27 개발 엔진으로 확인) + * 저장에 실패한 순간 사용자의 저장본이 사라진다. + */ + async saveDraft( + user: string, + content: DraftContent, + replaceId?: string, + ): Promise { + const session = await this.jmap.getSession(user); + const { inReplyTo } = content; + const prepared = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ['Identity/get', { accountId, ids: null }, 'i'], + ...(inReplyTo + ? [ + [ + 'Email/get', + { + accountId, + ids: [inReplyTo], + properties: ['id', 'messageId', 'references'], + }, + 'r', + ] as JmapInvocation, + ] + : []), + ], + { invalidRequestCode: 'VAL_001' }, + ); + + const draftsId = this.requireDraftsMailbox(prepared); + const { list: identities = [] } = expectResult>( + prepared, + 'i', + 'Identity/get', + ); + const identity = pickIdentity(identities, session.username); + if (!identity) { + throw new BusinessException('MAIL_001', { + context: { reason: 'identity-missing' }, + }); + } + + if (replaceId) { + // 옛 초안 확인은 따로 묻는다 — 엔진이 그 id 를 해석하지 못해 요청을 거부하면 "초안이 아닌 id" 와 같게 404 RES_001 + // (준비 요청은 VAL_001 이다 — 클라이언트는 RES_001 이면 새로 POST 한다) + const oldCheck = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { + accountId, + ids: [replaceId], + properties: DRAFT_CHECK_PROPERTIES, + }, + 'old', + ], + ], + { invalidRequestCode: 'RES_001' }, + ); + const old = expectResult>( + oldCheck, + 'old', + 'Email/get', + ).list?.[0]; + // 이미 없다 — 다른 창·기기에서 먼저 바꿨거나 지웠다. 아무것도 만들지 않았다 + if (!old) { + throw new BusinessException('MAIL_008', { + context: { reason: 'draft-already-gone' }, + }); + } + if (!isUserDraft(old, draftsId)) { + throw new BusinessException('RES_001', { + context: { reason: 'not-a-draft' }, + }); + } + } + + const original = inReplyTo + ? expectResult>(prepared, 'r', 'Email/get') + .list?.[0] + : undefined; + const thread = buildThreadHeaders(original); + + await this.assertAttachmentsFit(user, content.attachments, session); + + const draft = { + mailboxIds: { [draftsId]: true }, + keywords: { $draft: true, $seen: true }, + from: [ + { + name: identity.name || null, + email: identity.email ?? session.username, + }, + ], + ...(content.to.length ? { to: content.to.map(toJmapAddress) } : {}), + ...(content.cc.length ? { cc: content.cc.map(toJmapAddress) } : {}), + ...(content.bcc.length ? { bcc: content.bcc.map(toJmapAddress) } : {}), + subject: content.subject, + ...(thread ?? {}), + // 이어 쓸 때 돌려줄 답장 대상 — 원 메일을 찾았을 때만 (지워진 메일 id 는 남기지 않는다) + ...(original ? { [REPLY_TO_ID_PROPERTY]: original.id } : {}), + ...bodyProperties(content, []), + }; + + const created = await this.jmap.call( + user, + (accountId) => [['Email/set', { accountId, create: { draft } }, 'c']], + { invalidRequestCode: 'VAL_001', timeoutMs: JMAP_SEND_TIMEOUT_MS }, + ); + const set = expectResult(created, 'c', 'Email/set'); + const failure = firstSetError(set.notCreated); + if (failure && isMissingAttachmentBlob(failure)) { + throw new BusinessException('MAIL_004', { + context: { + reason: 'attachment-blob-not-found', + jmapType: failure.type ?? 'unknown', + }, + }); + } + if (failure) throw jmapFailure(failure, { method: 'Email/set' }); + const newId = set.created?.draft?.id; + if (!newId) { + throw new BusinessException('MAIL_001', { + context: { reason: 'draft-not-created' }, + }); + } + + let saved: SavedDraft; + try { + saved = await this.readSavedDraft(user, newId); + } catch (error) { + // 만든 초안을 확인하지 못했다 — 같은 내용으로 다시 저장할 때 두 통이 남지 않게 지운다 + await this.discardQuietly(user, newId, '확인하지 못한 새 임시 저장본'); + throw error; + } + + if (replaceId) await this.replaceOldDraft(user, replaceId, newId, draftsId); + return saved; + } + + async getDraft(user: string, id: string): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + [ + 'Email/get', + { + accountId, + ids: [id], + properties: DRAFT_DETAIL_PROPERTIES, + bodyProperties: BODY_PART_PROPERTIES, + fetchHTMLBodyValues: true, + fetchTextBodyValues: true, + maxBodyValueBytes: MAX_BODY_VALUE_BYTES, + }, + 'g', + ], + ], + { invalidRequestCode: 'RES_001' }, + ); + + const email = this.requireEmail(responses, 'g'); + if (!isUserDraft(email, this.requireDraftsMailbox(responses))) { + throw new BusinessException('RES_001', { + context: { reason: 'not-a-draft' }, + }); + } + + const detail = toDetailSource(email); + return { + id: detail.id, + to: detail.to, + cc: detail.cc, + bcc: detail.bcc, + subject: detail.subject, + textBody: detail.textBody, + rawHtmlBody: detail.rawHtmlBody, + attachments: detail.attachments, + inReplyTo: await this.existingReplyTarget(user, email), + updatedAt: detail.receivedAt, + }; + } + + async deleteDraft(user: string, id: string): Promise { + const lookup = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + [ + 'Email/get', + { accountId, ids: [id], properties: DRAFT_CHECK_PROPERTIES }, + 'g', + ], + ], + { invalidRequestCode: 'RES_001' }, + ); + const draftsId = this.requireDraftsMailbox(lookup); + const found = expectResult>( + lookup, + 'g', + 'Email/get', + ); + const email = found.list?.[0]; + // 이미 없다 — 지울 것이 없다. 초안이 아니면(보낸 메일 id·다른 메일함에도 있는 메일 등) 지우지 않는다 + if (!email) return; + if (!isUserDraft(email, draftsId)) { + this.warnNotDeletedDraft(user, id, '임시 저장본이 아닌 메일이라'); + return; + } + + // 확인한 상태 그대로일 때만 지운다 — 그사이 휴지통 등으로 옮겨졌으면 엔진이 거절하고 다시 확인한다 + const outcome = await this.destroyUserDraft( + user, + id, + draftsId, + typeof found.state === 'string' ? found.state : null, + ); + if (outcome === 'not-a-draft') { + this.warnNotDeletedDraft(user, id, '그사이 다른 곳으로 옮겨진 메일이라'); + } else if (outcome === 'contended') { + this.warnNotDeletedDraft( + user, + id, + '확인하는 사이 메일함이 계속 바뀌어 보낸 메일의 임시 저장본을', + 'MAIL_001', + ); + } + } + + async listCorrespondents( + user: string, + limits: { readonly sent: number; readonly received: number }, + ): Promise { + const lookup = await this.jmap.call(user, (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ]); + const { list: mailboxes = [] } = expectResult>( + lookup, + 'm', + 'Mailbox/get', + ); + const sources = [ + { + id: 's', + mailboxId: roleMailboxId(mailboxes, 'sent'), + limit: limits.sent, + properties: SENT_ADDRESS_PROPERTIES, + }, + { + id: 'r', + mailboxId: roleMailboxId(mailboxes, 'inbox'), + limit: limits.received, + properties: RECEIVED_ADDRESS_PROPERTIES, + }, + ].filter( + (source): source is typeof source & { mailboxId: string } => + source.mailboxId !== undefined && source.limit > 0, + ); + if (sources.length === 0) return []; + + // 두 메일함을 한 요청으로 — 최근 메일의 주소 머리말만 읽는다 (본문은 읽지 않는다) + const responses = await this.jmap.call(user, (accountId) => + sources.flatMap((source): JmapInvocation[] => [ + [ + 'Email/query', + { + accountId, + filter: { inMailbox: source.mailboxId }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: source.limit, + }, + `${source.id}q`, + ], + [ + 'Email/get', + { + accountId, + '#ids': { + resultOf: `${source.id}q`, + name: 'Email/query', + path: '/ids', + }, + properties: source.properties, + }, + `${source.id}g`, + ], + ]), + ); + + return sources.flatMap((source) => { + const { list = [] } = expectResult>( + responses, + `${source.id}g`, + 'Email/get', + ); + return list.flatMap((email) => { + const at = toUtcIso(email.receivedAt) ?? new Date(0).toISOString(); + const addresses = + source.id === 's' + ? [ + ...toAddresses(email.to), + ...toAddresses(email.cc), + ...toAddresses(email.bcc), + ] + : toAddresses(email.from); + return addresses.map((address) => ({ ...address, at })); + }); + }); + } + /** * 메일함 확인 + 목록 조회 + 요약 조회를 한 요청으로 보낸다. * 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다 @@ -754,9 +1363,13 @@ export class JmapMailProvider implements MailProvider { query: MessageListQuery, start: { anchor: string } | { position: number }, ): Promise { - const filter = query.text - ? { inMailbox: query.mailboxId, text: query.text } - : { inMailbox: query.mailboxId }; + // 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 — 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). + // 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다 + const filter = { + inMailbox: query.mailboxId, + notKeyword: INTERNAL_SEND_KEYWORD, + ...(query.text ? { text: query.text } : {}), + }; return this.jmap.call( user, @@ -786,6 +1399,8 @@ export class JmapMailProvider implements MailProvider { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: SUMMARY_PROPERTIES, + // 첨부 표시를 정할 만큼만 — 부분마다 형식·disposition·Content-ID·크기 (jmap.mapper.ts) + bodyProperties: SUMMARY_BODY_PART_PROPERTIES, }, 'g', ], @@ -944,6 +1559,245 @@ export class JmapMailProvider implements MailProvider { return email; } + /** 임시보관함 id — 없으면 저장할 곳이 없다 (MAIL_001) */ + private requireDraftsMailbox(responses: JmapInvocation[]): string { + const { list: mailboxes = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const draftsId = roleMailboxId(mailboxes, 'drafts'); + if (!draftsId) { + throw new BusinessException('MAIL_001', { + context: { reason: 'drafts-mailbox-missing' }, + }); + } + return draftsId; + } + + /** + * 방금 만든 임시 저장본의 첨부(새 id)와 저장 시각. + * 만들자마자 읽지 못하면 엔진 이상이다 — 클라이언트의 id 와 무관하므로 RES_001 이 아니라 MAIL_001. + */ + private async readSavedDraft(user: string, id: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Email/get', + { + accountId, + ids: [id], + properties: ['id', 'receivedAt', 'attachments'], + bodyProperties: BODY_PART_PROPERTIES, + }, + 'g', + ], + ]); + const [email] = + expectResult>(responses, 'g', 'Email/get') + .list ?? []; + if (!email) { + throw new BusinessException('MAIL_001', { + context: { reason: 'saved-draft-missing' }, + }); + } + const detail = toDetailSource({ ...email, id }); + return { + id, + attachments: detail.attachments, + updatedAt: detail.receivedAt, + }; + } + + /** + * 바꾼 임시 저장본의 옛것을 지운다 — 지우기 직전에 다시 확인한 상태 그대로일 때만(destroyUserDraft). + * 만들기 응답의 newState 를 조건으로 쓰지 않는다 — RFC 8620 은 그 요청의 oldState~newState 사이에 다른 요청의 변경이 끼지 않는다고 + * 보장하지 않는다 (큰 첨부를 붙여 만드는 수 초 동안 다른 탭이 옛것을 휴지통으로 옮기면 그 이동이 newState 에 들어간 채 통과할 수 있다). + * - 옛것이 그사이 없어졌으면(다른 곳에서 먼저 바꿈·지움) 방금 만든 것을 지우고 MAIL_008 + * - 그사이 사용자 초안이 아니게 됐으면(휴지통 등으로 옮겨짐) 지우지 않는다 — 새 저장본은 그대로, 저장은 성공 (경고) + * - 확인하는 사이 계속 어긋나거나 그 밖의 실패면 저장을 실패로 돌리지 않는다 — 옛것이 하나 더 남을 뿐이다 (경고) + */ + private async replaceOldDraft( + user: string, + oldId: string, + newId: string, + draftsId: string, + ): Promise { + let outcome: DraftDestroyOutcome; + try { + outcome = await this.destroyUserDraft(user, oldId, draftsId, null); + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} 옛 임시 저장본을 지우지 못해 두 통이 남을 수 있습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + if (outcome === 'gone') { + await this.discardQuietly(user, newId, '방금 만든 임시 저장본'); + throw new BusinessException('MAIL_008', { + context: { reason: 'draft-replaced-elsewhere' }, + }); + } + if (outcome === 'not-a-draft') { + this.logger.warn( + `[RES_001]${traceTag()} 옛 임시 저장본이 그사이 다른 곳으로 옮겨져 지우지 않았습니다 — 새 저장본은 그대로 둡니다: user=${maskEmail(user)}, id=${oldId}`, + ); + } else if (outcome === 'contended') { + this.logger.warn( + `[MAIL_001]${traceTag()} 확인하는 사이 메일함이 계속 바뀌어 옛 임시 저장본을 지우지 않았습니다 — 두 통이 남습니다: user=${maskEmail(user)}, id=${oldId}`, + ); + } + } + + /** + * 사용자 초안을 조건부로 지운다 — 사용자 초안임을 확인한 뒤 계정의 메일이 바뀌지 않았을 때만 (JMAP `ifInState`). + * 확인과 지우기 사이에 그 초안이 휴지통 등으로 옮겨졌다면 엔진이 `stateMismatch` 로 거절한다 — 그때는 다시 확인하고, + * 여전히 사용자 초안이면 그 상태로 다시 지운다 (지우기 요청은 모두 합쳐 CONDITIONAL_DESTROY_ATTEMPTS 번까지). + * 조건 없이 지우면 휴지통으로 옮긴 메일이 그대로 영구 삭제된다 (Email/set destroy 는 메일함과 상관없이 지운다). + * (2026-10-27 개발 엔진(Stalwart 0.16.27)으로 확인: 옛 상태의 destroy 는 stateMismatch 로 거절되고 메일은 남는다) + * @param state 사용자 초안임을 확인한 때의 Email 상태 — 모르면 null (먼저 확인한다) + * @throws 지우기의 그 밖의 실패 (SetError·메서드 오류 — notFound·stateMismatch 제외) + */ + private async destroyUserDraft( + user: string, + id: string, + draftsId: string, + state: string | null, + ): Promise { + let checked = state; + for ( + let attempt = 0; + attempt < CONDITIONAL_DESTROY_ATTEMPTS; + attempt += 1 + ) { + if (checked === null) { + const lookup = await this.jmap.call(user, (accountId) => [ + [ + 'Email/get', + { accountId, ids: [id], properties: DRAFT_CHECK_PROPERTIES }, + 'g', + ], + ]); + const found = expectResult>( + lookup, + 'g', + 'Email/get', + ); + const email = found.list?.[0]; + if (!email) return 'gone'; + if (!isUserDraft(email, draftsId)) return 'not-a-draft'; + // 상태를 모르면 조건 없이 지우지 않는다 + if (typeof found.state !== 'string') return 'contended'; + checked = found.state; + } + + const ifInState: string = checked; + const responses = await this.jmap.call(user, (accountId) => [ + ['Email/set', { accountId, ifInState, destroy: [id] }, 'x'], + ]); + const result = findResult(responses, 'x', 'Email/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) { + checked = null; + continue; + } + throw jmapFailure(result.error, { method: 'Email/set' }); + } + const failure = firstSetError( + (result.args as JmapSetResult).notDestroyed, + ); + if (!failure) return 'destroyed'; + if (failure.type === 'notFound') return 'gone'; + throw jmapFailure(failure, { method: 'Email/set' }); + } + return 'contended'; + } + + /** 보낸 뒤 임시 저장본을 지우지 않은 경우 — 경고만 (발송은 성공이다) */ + private warnNotDeletedDraft( + user: string, + id: string, + why: string, + code: 'RES_001' | 'MAIL_001' = 'RES_001', + ): void { + this.logger.warn( + `[${code}]${traceTag()} ${why} 지우지 않습니다: user=${maskEmail(user)}, id=${id}`, + ); + } + + /** 메일을 지운다 — 실패해도 던지지 않고 경고만 남긴다 (보정용) */ + private async discardQuietly( + user: string, + id: string, + what: string, + ): Promise { + try { + const responses = await this.jmap.call(user, (accountId) => [ + ['Email/set', { accountId, destroy: [id] }, 'x'], + ]); + const set = expectResult(responses, 'x', 'Email/set'); + const failure = firstSetError(set.notDestroyed); + if (failure && failure.type !== 'notFound') throw new Error(failure.type); + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} ${what}을 지우지 못했습니다 — 임시보관함에 한 통이 더 남습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** 임시 저장본이 답장하는 메일 id — 그 메일이 아직 있을 때만 (지워졌으면 null) */ + private async existingReplyTarget( + user: string, + draft: JmapEmail, + ): Promise { + const value = (draft as unknown as Record)[ + REPLY_TO_ID_PROPERTY + ]; + const id = typeof value === 'string' ? value.trim() : ''; + if (!MAIL_ID_PATTERN.test(id)) return null; + + const responses = await this.jmap.call( + user, + (accountId) => [ + ['Email/get', { accountId, ids: [id], properties: ['id'] }, 'r'], + ], + { invalidRequestCode: 'RES_001' }, + ); + const { list = [] } = expectResult>( + responses, + 'r', + 'Email/get', + ); + return list.length > 0 ? id : null; + } + + /** + * 메일 안에 함께 실을 그림을 엔진에 올린다 (서명 그림 — 한 장 300KB 이하, 3장까지). + * 한꺼번에 올리고 짧게 기다린다 — 일반 첨부 업로드의 제한 시간(120초)을 쓰면 발송이 클라이언트 한도(60초)를 넘는다. + */ + private async uploadInlineImages( + user: string, + images: readonly OutgoingInlineImage[] | undefined, + timeoutMs: number, + ): Promise { + return Promise.all( + (images ?? []).map(async (image) => { + const blob = await this.jmap.upload( + user, + new Blob([new Uint8Array(image.data)], { type: image.type }), + image.type, + { timeoutMs }, + ); + return { + blobId: blob.blobId, + cid: image.cid, + type: image.type, + name: image.name, + }; + }), + ); + } + /** 답장할 원 메일을 찾지 못하면 스레드 헤더 없이 보낸다 (지워진 메일에 답장하는 경우) */ private threadHeaders( prepared: JmapInvocation[], diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 0c14114..66867e2 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -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); diff --git a/backend/src/modules/mail/jmap/jmap.errors.spec.ts b/backend/src/modules/mail/jmap/jmap.errors.spec.ts index a24f6c7..d2194b6 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.spec.ts @@ -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); diff --git a/backend/src/modules/mail/jmap/jmap.errors.ts b/backend/src/modules/mail/jmap/jmap.errors.ts index 4d4b4fe..50f7a01 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.ts @@ -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 로 응답한다. diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 44bb194..2476c7e 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -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'); diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index df83f23..71eaf95 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -102,11 +102,17 @@ export interface JmapIdentity { } export interface JmapGetResult { + /** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */ + state?: string; list?: T[]; notFound?: string[]; } export interface JmapSetResult { + /** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */ + oldState?: string | null; + /** 이 요청이 바꾼 뒤의 상태 */ + newState?: string; created?: Record; updated?: Record; destroyed?: string[]; diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 6be6145..373e565 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -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; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 84853bb..64570e5 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -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(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], diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 3f27a43..1eb0dee 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -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; + + /** + * 임시 저장 (계약 2절 "임시 저장") — 임시보관함에 새 초안을 만든다. `replaceId` 가 있으면 그 초안을 바꾼다: + * - 메일은 고칠 수 없어 새 초안을 **만든 뒤에** 옛것을 지운다 — 만들기가 실패하면 옛것은 그대로다 + * - `replaceId` 가 임시보관함의 사용자 초안이 아니면 아무것도 만들지 않고 RES_001 + * - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 바꿨거나 지웠음) 방금 만든 것을 지우고 MAIL_008 + * 발송 중복 방지용 Message-ID·지문·발송 중 표시는 넣지 않는다. 첨부 합계·없는 첨부는 발송과 같다(FILE_002·MAIL_004). + */ + saveDraft( + user: string, + content: DraftContent, + replaceId?: string, + ): Promise; + + /** 쓰기 화면이 이어 쓸 임시 저장본 — 임시보관함의 사용자 초안이 아니면(발송 중에 생긴 내부 초안 포함) RES_001 */ + getDraft(user: string, id: string): Promise; + + /** + * 임시 저장본을 지운다 (보낸 뒤) — 사용자 초안만 지운다. 이미 없거나 초안이 아니면 아무것도 하지 않는다 + * (클라이언트가 보낸 id 로 다른 메일을 지우지 않게). + */ + deleteDraft(user: string, id: string): Promise; + + /** + * 주고받은 주소 (받는 사람 추천) — 그 직원의 보낸편지함 최근 `sent` 통의 받는 사람·참조·숨은참조와 + * 받은편지함 최근 `received` 통의 보낸 사람. 다른 직원의 메일함은 읽지 않는다. + */ + listCorrespondents( + user: string, + limits: { readonly sent: number; readonly received: number }, + ): Promise; } diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 7e60d62..78ab28d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -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, + Parameters + >; + loadImages: jest.Mock< + ReturnType, + Parameters + >; +} + +function fakeSignatures(): SignatureMocks { + return { + imageIdsOf: jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(new Set()), + loadImages: jest + .fn< + ReturnType, + Parameters + >() + .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, ): Promise { @@ -130,15 +175,45 @@ const sendDto = (overrides: Partial = {}): SendMessageDto => ({ ...overrides, }); +const draftDto = (overrides: Partial = {}): DraftBodyDto => ({ + to: [], + subject: '쓰는 중', + body: '본문', + ...overrides, +}); + +const draftSource = (overrides: Partial = {}): 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 }; 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() }; + 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: '

본문

', + 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: '

본문

', + 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: '

서명

', + 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 [ + ['

그림을 가리키지 않는 본문

', 3], + ['', 5 * 1024 * 1024], + ['

본문

', 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: '', + 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: `

김아라 드림

아라시스템`, + }), + ); + + expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [ + MY_IMAGE_ID, + ]); + const [, outgoing] = provider.sendMessage.mock.calls[0]; + expect(outgoing.htmlBody).toBe( + `

김아라 드림

아라시스템`, + ); + 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: `

서명

`, + }); + + 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: `

앞가운데뒤

`, + }), + ); + + expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [ + MY_IMAGE_ID, + ]); + const [, outgoing] = provider.sendMessage.mock.calls[0]; + expect(outgoing.htmlBody).toBe('

앞가운데뒤

'); + 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) => ``; + const tooMany = `

서명

${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: + '

그림 없는 메일

', + }), + ); + + expect(signatures.imageIdsOf).not.toHaveBeenCalled(); + expect(signatures.loadImages).not.toHaveBeenCalled(); + expect(provider.sendMessage.mock.calls[0][1].htmlBody).toBe( + '

그림 없는 메일

', + ); + }); + }); + + /** 계약 2절 "임시 저장" — 발송과 같은 준비(정화·한도), 받는 사람 0명도 된다 */ + describe('임시 저장', () => { + it('받는 사람이 없어도 저장한다 — 보내는 정화를 거친 서식 본문을 넘기고, 중복 방지 Message-ID·지문은 넣지 않는다', async () => { + await expect( + service.createDraft( + user, + draftDto({ + subject: '줄\r\n바꿈', + bodyHtml: '

쓰는 중

', + 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: '

쓰는 중

', + 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, 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: `

김아라

`, + }), + ); + + expect(signatures.imageIdsOf).toHaveBeenCalledWith(user.email); + expect(signatures.loadImages).not.toHaveBeenCalled(); + expect(provider.saveDraft.mock.calls[0][1].htmlBody).toBe( + `

김아라

`, + ); + }); + + describe('이어 쓰기', () => { + it('엔진의 초안을 계약 모양으로 — 서식 본문은 보내는 정화를 거친다 (다른 메일 프로그램이 만든 초안도)', async () => { + signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID])); + provider.getDraft.mockResolvedValue( + draftSource({ + rawHtmlBody: `

제목

본문

`, + 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: `제목

본문

`, + 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, '', '']) { + 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((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', diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 972db96..2b21663 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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 = new Set(); + +/** 본문에 그림으로 넣은 첨부가 없다 (HTML 없이 텍스트 본문을 보여 줄 때) */ +const NO_INLINE_ATTACHMENTS: ReadonlySet = new Set(); + +/** 본문에 넣으려고 읽은 내장 이미지 — 정규화한 Content-ID 로 찾는다 */ +interface LoadedInlineImages { + /** Content-ID → data: URI (정화가 cid: 를 이것으로 바꾼다) */ + readonly dataUris: Map; + /** Content-ID → 그 그림을 가져온 첨부의 위치 (attachments 의 몇 번째) */ + readonly attachmentIndexes: Map; +} + +/* + * 본문 한도 — 넘으면 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): 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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> { - const images = new Map(); + ): Promise { + 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; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 1aad3da..d8272ab 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -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 */ diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index 0df0bdf..26c5fde 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -151,10 +151,12 @@ export class MessageController { 'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' + '같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' + '서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' + + '보내는 사람 자신의 서명 그림()만 남겨 메일 안에 함께 싣는다(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', diff --git a/backend/src/modules/mail/recipient-ranking.spec.ts b/backend/src/modules/mail/recipient-ranking.spec.ts new file mode 100644 index 0000000..713bb8b --- /dev/null +++ b/backend/src/modules/mail/recipient-ranking.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/mail/recipient-ranking.ts b/backend/src/modules/mail/recipient-ranking.ts new file mode 100644 index 0000000..cb2d204 --- /dev/null +++ b/backend/src/modules/mail/recipient-ranking.ts @@ -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; + 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, +): 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 { + const tallies = new Map(); + 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(); + 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)), + ); +} diff --git a/backend/src/modules/mail/recipient-suggestion-cursor.spec.ts b/backend/src/modules/mail/recipient-suggestion-cursor.spec.ts new file mode 100644 index 0000000..e37d8bb --- /dev/null +++ b/backend/src/modules/mail/recipient-suggestion-cursor.spec.ts @@ -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'); + } + }); +}); diff --git a/backend/src/modules/mail/recipient-suggestion-cursor.ts b/backend/src/modules/mail/recipient-suggestion-cursor.ts new file mode 100644 index 0000000..512e3c9 --- /dev/null +++ b/backend/src/modules/mail/recipient-suggestion-cursor.ts @@ -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; + 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; +} diff --git a/backend/src/modules/mail/recipient-suggestion.controller.ts b/backend/src/modules/mail/recipient-suggestion.controller.ts new file mode 100644 index 0000000..ede8672 --- /dev/null +++ b/backend/src/modules/mail/recipient-suggestion.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/recipient-suggestions.service.spec.ts b/backend/src/modules/mail/recipient-suggestions.service.spec.ts new file mode 100644 index 0000000..13688c0 --- /dev/null +++ b/backend/src/modules/mail/recipient-suggestions.service.spec.ts @@ -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 => ({ limit: 20, ...overrides }); + +describe('RecipientSuggestionService', () => { + let listCorrespondents: jest.Mock< + Promise, + Parameters + >; + let listContacts: jest.Mock, [number?]>; + let service: RecipientSuggestionService; + let debugSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + listCorrespondents = jest + .fn< + Promise, + Parameters + >() + .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, [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; + 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; + + 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|고객|이도윤/); + }); +}); diff --git a/backend/src/modules/mail/recipient-suggestions.service.ts b/backend/src/modules/mail/recipient-suggestions.service.ts new file mode 100644 index 0000000..1bb5e9a --- /dev/null +++ b/backend/src/modules/mail/recipient-suggestions.service.ts @@ -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; +} + +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(); + /** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */ + private readonly collecting = new Map(); + private readonly excluded: ReadonlySet; + + 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> { + 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 { + 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 { + 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); + } + } +} diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts index ec068ee..293608f 100644 --- a/backend/src/shared/utils/mask.spec.ts +++ b/backend/src/shared/utils/mask.spec.ts @@ -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('첫 글자와 도메인만 남기고 나머지를 가린다', () => { diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts index 744d42e..8ba20c1 100644 --- a/backend/src/shared/utils/mask.ts +++ b/backend/src/shared/utils/mask.ts @@ -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}`; +} diff --git a/backend/src/shared/utils/settle.spec.ts b/backend/src/shared/utils/settle.spec.ts new file mode 100644 index 0000000..70b78ce --- /dev/null +++ b/backend/src/shared/utils/settle.spec.ts @@ -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((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); + }); +}); diff --git a/backend/src/shared/utils/settle.ts b/backend/src/shared/utils/settle.ts new file mode 100644 index 0000000..da4266a --- /dev/null +++ b/backend/src/shared/utils/settle.ts @@ -0,0 +1,19 @@ +/** + * 정해진 시간까지만 기다린다 — 그 안에 끝나면 true, 아니면 false (작업은 멈추지 않고 뒤에서 계속된다). + * 작업이 실패해도 "끝난 것"으로 본다 — 실패 처리(로그 등)는 작업 쪽이 한다. 이 함수는 결과를 바꾸지 않는다. + * 기다림 타이머는 프로세스를 붙잡지 않는다(unref) — 종료가 늦어지지 않게. + */ +export function settledWithin( + work: Promise, + ms: number, +): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms); + timer.unref(); + const finish = () => { + clearTimeout(timer); + resolve(true); + }; + work.then(finish, finish); + }); +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 47ba6d8..f15b46f 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -12,6 +12,7 @@ import { DataSource } from 'typeorm'; import { AppModule } from '@/app.module'; import { configureApp } from '@/app.setup'; +import { BusinessException } from '@/common/errors/business.exception'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; import { MAX_BODY_HTML_LENGTH, @@ -20,7 +21,22 @@ import { } from '@/modules/mail/mail.constants'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import type { + Correspondence, + DraftContent, + DraftSource, + OutgoingMessage, + SavedDraft, +} from '@/modules/mail/mail.types'; +import { + SIGNATURE_IMAGES_PER_SIGNATURE, + SIGNATURE_IMAGES_STORED_MAX, + SIGNATURE_IMAGE_DIR, + SIGNATURE_IMAGE_MAX_BYTES, +} from '@/modules/mail-signature/mail-signature.constants'; +import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; import { UPLOAD_DIR } from '@/modules/upload/upload.config'; +import { pngBytes } from './image-fixtures'; /** * 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다. @@ -55,6 +71,9 @@ interface ContractBody { const body = (res: Response): ContractBody => res.body as ContractBody; +/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */ +const dataOf = (res: Response): T => (res.body as { data: T }).data; + /** Set-Cookie 헤더 중 이름이 같은 것 */ function setCookie(res: Response, name: string): string | undefined { const raw = res.headers['set-cookie'] as unknown; @@ -78,6 +97,15 @@ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test'; /** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test'; +/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */ +const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere'; + +/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */ +const PROFILE_NAMES = new Map([ + ['colleague.a@e2e.arasystem.test', '가나다'], + ['colleague.b@e2e.arasystem.test', '박서준'], +]); + /** 문을 열 조건을 다시 보는 간격 */ const GATE_POLL_MS = 10; /** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */ @@ -150,6 +178,18 @@ describe('응답 계약 (e2e)', () => { * 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다). */ let slowEngineGate: Promise | null = null; + /** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */ + const drafts = new Map< + string, + { user: string; content: DraftContent; updatedAt: string } + >(); + let draftSequence = 0; + /** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */ + const deletedDrafts: string[] = []; + /** 대역 엔진이 받은 발송 */ + const sentMessages: { user: string; message: OutgoingMessage }[] = []; + /** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */ + const correspondents = new Map(); /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => @@ -188,13 +228,79 @@ describe('응답 계약 (e2e)', () => { .useValue({ // 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다 getProfile: (email: string) => - Promise.resolve({ email, name: EMPLOYEE.name }), + Promise.resolve({ + email, + name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, + }), verifyCredentials: async (email: string, password: string) => { engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1); // 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다 if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate; return password === CORRECT_PASSWORD ? email : null; }, + // 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다 + findSentMessage: () => Promise.resolve(null), + sendMessage: (user: string, message: OutgoingMessage) => { + sentMessages.push({ user, message }); + return Promise.resolve({ id: `sent${sentMessages.length}` }); + }, + // 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts) + saveDraft: ( + user: string, + content: DraftContent, + replaceId?: string, + ): Promise => { + if (replaceId === DRAFT_CHANGED_ELSEWHERE) { + return Promise.reject( + new BusinessException('MAIL_008', { + context: { reason: 'e2e-draft-replaced-elsewhere' }, + }), + ); + } + if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-not-a-draft' }, + }), + ); + } + draftSequence += 1; + const id = `draft${draftSequence}`; + const updatedAt = new Date().toISOString(); + drafts.set(id, { user, content, updatedAt }); + if (replaceId !== undefined) drafts.delete(replaceId); + return Promise.resolve({ id, attachments: [], updatedAt }); + }, + getDraft: (user: string, id: string): Promise => { + const draft = drafts.get(id); + if (!draft || draft.user !== user) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-not-a-draft' }, + }), + ); + } + const { content, updatedAt } = draft; + return Promise.resolve({ + id, + to: content.to, + cc: content.cc, + bcc: content.bcc, + subject: content.subject, + textBody: content.body, + rawHtmlBody: content.htmlBody ?? null, + attachments: [], + inReplyTo: null, + updatedAt, + }); + }, + deleteDraft: (user: string, id: string) => { + deletedDrafts.push(id); + if (drafts.get(id)?.user === user) drafts.delete(id); + return Promise.resolve(); + }, + listCorrespondents: (user: string) => + Promise.resolve(correspondents.get(user) ?? []), }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -222,6 +328,22 @@ describe('응답 계약 (e2e)', () => { 'DELETE FROM auth_sessions WHERE user_email LIKE $1', [TEST_DOMAIN_PATTERN], ); + // 서명 그림은 파일도 지운다 (개발 업로드 볼륨에 남기지 않는다) + const images: { file_name: string }[] = await dataSource.query( + 'SELECT file_name FROM mail_signature_images WHERE user_email LIKE $1', + [TEST_DOMAIN_PATTERN], + ); + for (const { file_name: fileName } of images) { + const stored = join(SIGNATURE_IMAGE_DIR, basename(fileName)); + if (existsSync(stored)) unlinkSync(stored); + } + for (const statement of [ + 'DELETE FROM mail_signature_images WHERE user_email LIKE $1', + 'DELETE FROM mail_signatures WHERE user_email LIKE $1', + 'DELETE FROM mail_directory WHERE email LIKE $1', + ]) { + await dataSource.query(statement, [TEST_DOMAIN_PATTERN]); + } await app.close(); }); @@ -544,6 +666,17 @@ describe('응답 계약 (e2e)', () => { ['get', '/api/messages/eaaaaab'], ['post', '/api/messages'], ['post', '/api/mail-attachments'], + ['get', '/api/drafts/eaaaaab'], + ['post', '/api/drafts'], + ['put', '/api/drafts/eaaaaab'], + ['get', '/api/mail-signature'], + ['put', '/api/mail-signature'], + ['post', '/api/mail-signature/images'], + [ + 'get', + '/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + ], + ['get', '/api/recipient-suggestions'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], ] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => { @@ -594,7 +727,8 @@ describe('응답 계약 (e2e)', () => { { sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name }, 600, ); - // 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다) + // 한도는 엔진에 묻기 전에 본다 — 대역 엔진이 받은 발송이 늘지 않아야 한다 + const sentBefore = sentMessages.length; const send = (overrides: object) => request(server) .post('/api/messages') @@ -620,6 +754,644 @@ describe('응답 계약 (e2e)', () => { subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1), }).expect(400); expect(body(longSubject).error?.code).toBe('VAL_001'); + expect(sentMessages).toHaveLength(sentBefore); + }); + }); + + /** + * 2단계 — 임시 저장 · 서명 · 받는 사람 추천 (계약 2절). + * 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다. + */ + describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => { + /** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */ + const cookieOf = (email: string) => + `access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`; + const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }; + + describe('임시 저장 (/api/drafts)', () => { + const save = (method: 'post' | 'put', path: string, payload: object) => + request(server) + [method](path) + .set('Cookie', cookieOf(EMPLOYEE.email)) + .send({ to: [], subject: '쓰는 중', body: '본문', ...payload }); + + it('받는 사람 없이도 처음 저장(POST)은 201 — 서식 본문은 보내는 정화를 거쳐 엔진에 넘어간다', async () => { + const res = await save('post', '/api/drafts', { + bodyHtml: '

쓰는 중

', + }).expect(201); + + const saved = dataOf(res); + expect(saved).toEqual({ + id: expect.stringMatching(/^draft\d+$/) as string, + attachments: [], + updatedAt: expect.any(String) as string, + }); + expect(drafts.get(saved.id)?.content).toMatchObject({ + to: [], + htmlBody: '

쓰는 중

', + }); + }); + + it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => { + const first = dataOf( + await save('post', '/api/drafts', {}).expect(201), + ); + const second = dataOf( + await save('put', `/api/drafts/${first.id}`, { + to: [DOYUN], + subject: '고친 제목', + bodyHtml: + '

굵게

', + }).expect(200), + ); + expect(second.id).not.toBe(first.id); + expect(drafts.has(first.id)).toBe(false); + + const res = await request(server) + .get(`/api/drafts/${second.id}`) + .set('Cookie', cookieOf(EMPLOYEE.email)) + .expect(200); + expect(dataOf(res)).toEqual({ + id: second.id, + to: [DOYUN], + cc: [], + bcc: [], + subject: '고친 제목', + body: '본문', + bodyHtml: '

굵게

', + attachments: [], + inReplyTo: null, + updatedAt: second.updatedAt, + }); + }); + + it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => { + const changed = await save( + 'put', + `/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`, + {}, + ).expect(409); + expect(body(changed).error?.code).toBe('MAIL_008'); + expect(body(changed).error?.traceId).toBe( + changed.headers['x-request-id'], + ); + + for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) { + const res = await save('put', path, {}).expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + }); + + it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => { + const mine = dataOf( + await save('post', '/api/drafts', {}).expect(201), + ); + + const res = await request(server) + .get(`/api/drafts/${mine.id}`) + .set('Cookie', cookieOf('other.person@e2e.arasystem.test')) + .expect(404); + expect(body(res).error?.code).toBe('RES_001'); + }); + + it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => { + const recipients = (count: number) => + Array.from({ length: count }, (_, index) => ({ + name: null, + email: `r${index}@e2e.arasystem.test`, + })); + const cases: [object, number, string][] = [ + [{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'], + [{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'], + [{ draftId: 'eaaaaab' }, 400, 'VAL_001'], + ]; + + const before = drafts.size; + for (const [payload, status, code] of cases) { + const res = await save('post', '/api/drafts', payload).expect(status); + expect(body(res).error?.code).toBe(code); + } + expect(drafts.size).toBe(before); + }); + + it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => { + for (const [method, path] of [ + ['post', '/api/drafts'], + ['put', '/api/drafts/eaaaaab'], + ] as const) { + const parsed = await request(server) + [method](path) + .set('Content-Type', 'application/json') + .send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) })) + .expect(401); + expect(body(parsed).error?.code).toBe('AUTH_001'); + + const tooLarge = await request(server) + [method](path) + .set('Content-Type', 'application/json') + .send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) })) + .expect(413); + expect(body(tooLarge).error?.code).toBe('FILE_002'); + } + }); + + it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => { + const draft = dataOf( + await save('post', '/api/drafts', { to: [DOYUN] }).expect(201), + ); + const send = (draftId: string) => + request(server) + .post('/api/messages') + .set('Cookie', cookieOf(EMPLOYEE.email)) + .send({ to: [DOYUN], subject: '제목', body: '본문', draftId }); + + const sentBefore = sentMessages.length; + const malformed = await send('../draft1').expect(400); + expect(body(malformed).error?.code).toBe('VAL_001'); + expect(sentMessages).toHaveLength(sentBefore); + + await send(draft.id).expect(201); + expect(sentMessages).toHaveLength(sentBefore + 1); + // 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다) + expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId'); + expect(deletedDrafts).toContain(draft.id); + expect(drafts.has(draft.id)).toBe(false); + }); + }); + + describe('서명 (/api/mail-signature)', () => { + const imageUrl = (id: string) => `/api/mail-signature/images/${id}`; + const uploadAs = ( + email: string, + data: Buffer, + filename = 'logo.png', + contentType = 'image/png', + ) => + request(server) + .post('/api/mail-signature/images') + .set('Cookie', cookieOf(email)) + .attach('file', data, { filename, contentType }); + const uploaded = async (email: string, data = pngBytes(480, 120)) => + dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id; + const saveSignature = (email: string, payload: object) => + request(server) + .put('/api/mail-signature') + .set('Cookie', cookieOf(email)) + .send({ isForNew: true, isForReply: true, ...payload }); + + it('저장한 적 없으면 기본값', async () => { + const res = await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf('sign.default@e2e.arasystem.test')) + .expect(200); + + expect(dataOf(res)).toEqual({ + bodyHtml: null, + body: null, + isForNew: true, + isForReply: true, + images: [], + updatedAt: null, + }); + }); + + it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => { + const owner = 'sign.owner@e2e.arasystem.test'; + const data = pngBytes(480, 120, 2_048); + + const image = dataOf>( + await uploadAs(owner, data, '회사 로고.png').expect(201), + ); + expect(image).toEqual({ + id: expect.stringMatching(UUID) as string, + type: 'image/png', + size: 2_048, + width: 480, + height: 120, + }); + const id = String(image.id); + + const res = await request(server) + .get(imageUrl(id)) + .set('Cookie', cookieOf(owner)) + .expect(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['cache-control']).toBe('private, max-age=86400'); + expect(res.headers['content-disposition']).toBe( + 'inline; filename="signature.png"', + ); + expect(res.body).toEqual(data); + + for (const [cookie, path] of [ + [cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)], + [cookieOf(owner), imageUrl('not-a-uuid')], + [cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')], + ]) { + const denied = await request(server) + .get(path) + .set('Cookie', cookie) + .expect(404); + expect(body(denied).error?.code).toBe('RES_001'); + } + }); + + it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => { + const user = 'sign.reject@e2e.arasystem.test'; + for (const [data, filename, contentType] of [ + // 이름·MIME 은 PNG 인데 내용은 HTML + [ + Buffer.from(''), + 'logo.png', + 'image/png', + ], + // 이름과 MIME 이 다르다 + [pngBytes(10, 10), 'logo.png', 'image/gif'], + [pngBytes(10, 10), 'logo.webp', 'image/webp'], + [Buffer.from(''), 'logo.svg', 'image/svg+xml'], + ] as const) { + const res = await uploadAs(user, data, filename, contentType).expect( + 400, + ); + expect(body(res).error?.code).toBe('FILE_001'); + } + + const tooLarge = await uploadAs( + user, + pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1), + ).expect(413); + expect(body(tooLarge).error?.code).toBe('FILE_002'); + + const missing = await request(server) + .post('/api/mail-signature/images') + .set('Cookie', cookieOf(user)) + .expect(400); + expect(body(missing).error?.code).toBe('VAL_001'); + + // 하나도 저장되지 않았다 + const rows: { id: string }[] = await dataSource.query( + 'SELECT id FROM mail_signature_images WHERE user_email = $1', + [user], + ); + expect(rows).toEqual([]); + }); + + it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => { + const user = 'sign.many@e2e.arasystem.test'; + const ids: string[] = []; + for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) { + ids.push(await uploaded(user)); + } + + const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400); + expect(body(overflow).error?.code).toBe('VAL_001'); + + const img = (id: string) => ``; + const tooMany = await saveSignature(user, { + bodyHtml: ids + .slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1) + .map(img) + .join(''), + }).expect(400); + expect(body(tooMany).error?.code).toBe('VAL_001'); + + const fits = await saveSignature(user, { + bodyHtml: ids + .slice(0, SIGNATURE_IMAGES_PER_SIGNATURE) + .map(img) + .join(''), + }).expect(200); + expect( + dataOf<{ images: { id: string }[] }>(fits).images.map( + (image) => image.id, + ), + ).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)); + }); + + it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => { + const user = 'sign.save@e2e.arasystem.test'; + const [a, b] = [await uploaded(user), await uploaded(user)]; + + const first = await saveSignature(user, { + bodyHtml: `

김아라

`, + body: '김아라 · 아라시스템', + isForReply: false, + }).expect(200); + expect(dataOf>(first)).toMatchObject({ + bodyHtml: `

김아라

`, + body: '김아라 · 아라시스템', + isForNew: true, + isForReply: false, + images: [ + expect.objectContaining({ id: a }) as unknown, + expect.objectContaining({ id: b }) as unknown, + ], + updatedAt: expect.any(String) as string, + }); + + // b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다 + const c = await uploaded(user); + const second = await saveSignature(user, { + bodyHtml: ``, + body: null, + }).expect(200); + expect( + dataOf<{ images: { id: string }[] }>(second).images.map( + (image) => image.id, + ), + ).toEqual([a]); + + const statusOf = async (id: string) => + ( + await request(server) + .get(imageUrl(id)) + .set('Cookie', cookieOf(user)) + ).status; + expect(await statusOf(a)).toBe(200); + expect(await statusOf(b)).toBe(404); + expect(await statusOf(c)).toBe(200); + // 지운 그림은 파일도 없다 + expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false); + + const current = await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf(user)) + .expect(200); + expect(dataOf(current)).toEqual(dataOf(second)); + }); + + it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => { + const user = 'sign.limits@e2e.arasystem.test'; + for (const payload of [ + { body: '가'.repeat(2_001) }, + { bodyHtml: `

${'가'.repeat(10_000)}

` }, + ]) { + const res = await saveSignature(user, payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => { + const user = 'sign.body@e2e.arasystem.test'; + const ids: string[] = []; + for ( + let index = 0; + index < SIGNATURE_IMAGES_PER_SIGNATURE + 1; + index += 1 + ) { + ids.push(await uploaded(user, pngBytes(40, 10))); + } + const bodyHtml = `

본문

${ids.map((id) => ``).join('')}`; + const sentBefore = sentMessages.length; + const draftsBefore = drafts.size; + + const send = await request(server) + .post('/api/messages') + .set('Cookie', cookieOf(user)) + .send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml }) + .expect(400); + expect(body(send).error?.code).toBe('VAL_001'); + const draft = await request(server) + .post('/api/drafts') + .set('Cookie', cookieOf(user)) + .send({ to: [], subject: '그림 넷', body: '본문', bodyHtml }) + .expect(400); + expect(body(draft).error?.code).toBe('VAL_001'); + + expect(sentMessages).toHaveLength(sentBefore); + expect(drafts.size).toBe(draftsBefore); + }); + + it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => { + const user = 'sign.race@e2e.arasystem.test'; + const [a, b] = [await uploaded(user), await uploaded(user)]; + const img = (id: string) => ``; + await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect( + 200, + ); + + // 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다 + const [first, second] = await Promise.all([ + saveSignature(user, { bodyHtml: `

탭 1

${img(a)}` }), + saveSignature(user, { bodyHtml: `

탭 2

${img(b)}` }), + ]); + expect([first.status, second.status]).toEqual([200, 200]); + + const final = dataOf<{ + bodyHtml: string | null; + images: { id: string }[]; + }>( + await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf(user)) + .expect(200), + ); + const referenced = [ + ...(final.bodyHtml ?? '').matchAll( + /\/api\/mail-signature\/images\/([0-9a-f-]{36})/g, + ), + ].map((match) => match[1]); + expect(final.images.map((image) => image.id)).toEqual(referenced); + // 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다 + for (const id of [a, b]) { + const res = await request(server) + .get(imageUrl(id)) + .set('Cookie', cookieOf(user)); + expect(res.status).toBe(referenced.includes(id) ? 200 : 404); + } + }); + + it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => { + const user = 'sign.cleanup@e2e.arasystem.test'; + const [attached, loose] = [await uploaded(user), await uploaded(user)]; + await saveSignature(user, { + bodyHtml: ``, + }).expect(200); + + // 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다 + const repository = app.get(MailSignatureRepository, { strict: false }); + const deleted = await repository.deleteUnattachedImages([ + attached, + loose, + ]); + // 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게) + const left: { id: string }[] = await dataSource.query( + 'SELECT id FROM mail_signature_images WHERE id = ANY($1)', + [[attached, loose]], + ); + for (const id of [attached, loose]) { + const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`); + if (!left.some((row) => row.id === id) && existsSync(stored)) { + unlinkSync(stored); + } + } + expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]); + + const statusOf = async (id: string) => + ( + await request(server) + .get(imageUrl(id)) + .set('Cookie', cookieOf(user)) + ).status; + expect(await statusOf(attached)).toBe(200); + expect(await statusOf(loose)).toBe(404); + }); + + it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => { + const user = 'sign.send@e2e.arasystem.test'; + const data = pngBytes(200, 50, 1_000); + const id = await uploaded(user, data); + const html = `

본문

로고`; + const cid = `sig-${id}@e2e.arasystem.test`; + + await request(server) + .post('/api/messages') + .set('Cookie', cookieOf(user)) + .send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html }) + .expect(201); + const sent = sentMessages[sentMessages.length - 1]; + expect(sent.user).toBe(user); + expect(sent.message.htmlBody).toBe( + `

본문

로고`, + ); + expect(sent.message.inlineImages).toEqual([ + { cid, type: 'image/png', name: 'signature-1.png', data }, + ]); + + const saved = dataOf( + await request(server) + .post('/api/drafts') + .set('Cookie', cookieOf(user)) + .send({ to: [], subject: '', body: '본문', bodyHtml: html }) + .expect(201), + ); + expect(drafts.get(saved.id)?.content.htmlBody).toBe( + `

본문

로고`, + ); + }); + }); + + describe('받는 사람 추천 (/api/recipient-suggestions)', () => { + const ME = 'suggest.me@e2e.arasystem.test'; + const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test'; + const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test'; + const suggest = (params: Record) => + request(server) + .get('/api/recipient-suggestions') + .query(params) + .set('Cookie', cookieOf(ME)); + interface SuggestionPage { + items: { name: string | null; email: string; source: string }[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number; + }; + } + + beforeAll(async () => { + // 사내 목록은 로그인에 성공한 직원으로 만든다 + await login(COLLEAGUE_A); + await login(COLLEAGUE_B); + const now = new Date().toISOString(); + correspondents.set(ME, [ + { name: '박서준', email: COLLEAGUE_B, at: now }, + { + name: '협력사', + email: 'partner@vendor.e2e.arasystem.test', + at: now, + }, + { name: null, email: 'no-reply@notice.e2e.arasystem.test', at: now }, + { name: null, email: 'mail-gateway@e2e.arasystem.test', at: now }, + { name: '나', email: ME, at: now }, + ]); + }); + + it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => { + // 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다 + const res = await suggest({ + q: 'e2e.arasystem.test', + limit: 100, + }).expect(200); + + expect(res.headers['cache-control']).toBe('private, no-store'); + const { items } = dataOf(res); + const emails = items.map((item) => item.email); + expect(items).toContainEqual({ + name: '가나다', + email: COLLEAGUE_A, + source: 'directory', + }); + // 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만 + expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan( + emails.indexOf(COLLEAGUE_A), + ); + expect(emails.filter((email) => email === COLLEAGUE_B)).toHaveLength(1); + expect(items[items.length - 1]).toEqual({ + name: '협력사', + email: 'partner@vendor.e2e.arasystem.test', + source: 'recent', + }); + expect(emails).not.toContain(ME); + expect(emails).not.toContain('mail-gateway@e2e.arasystem.test'); + expect(emails).not.toContain('no-reply@notice.e2e.arasystem.test'); + }); + + it('q 와 커서 페이지네이션 — 다른 q 의 커서 · 틀린 커서 · 100자 넘는 q · limit 범위 밖은 400 VAL_001', async () => { + const first = dataOf( + await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200), + ); + expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 }); + const cursor = first.pageInfo.nextCursor ?? ''; + + const second = dataOf( + await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200), + ); + expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]); + expect(second.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 2, + }); + + const rejected: Record[] = [ + { q: 'partner', cursor }, + { cursor: 'not-a-cursor' }, + { q: 'x'.repeat(101) }, + { limit: 101 }, + ]; + for (const params of rejected) { + const res = await suggest(params).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => { + const now = new Date().toISOString(); + correspondents.set(ME, [ + { + name: '새 거래처', + email: 'new@client.e2e.arasystem.test', + at: now, + }, + ]); + const emailsOf = async () => + dataOf( + await suggest({ q: 'client.e2e', limit: 100 }).expect(200), + ).items.map((item) => item.email); + + expect(await emailsOf()).toEqual([]); + + await request(server) + .post('/api/messages') + .set('Cookie', cookieOf(ME)) + .send({ to: [DOYUN], subject: '제목', body: '본문' }) + .expect(201); + expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']); + }); }); }); diff --git a/backend/test/image-fixtures.ts b/backend/test/image-fixtures.ts new file mode 100644 index 0000000..0057164 --- /dev/null +++ b/backend/test/image-fixtures.ts @@ -0,0 +1,75 @@ +/** + * 서명 그림 테스트용 그림 파일 머리 — 테스트 전용 도우미 (앱 코드에서 import 하지 않는다). + * + * 형식 확인(signature-image-format.ts)은 파일 앞부분만 본다 — 여기서는 그 앞부분과, 크기를 맞출 채움 바이트만 만든다 + * (실제로 그릴 수 있는 그림일 필요는 없다). + * + * 위치: src 밖(test/) — cpu-time.ts 와 같은 이유로 빌드·커버리지에 들어가지 않는다. + * 단위 테스트는 `@/../test/image-fixtures`, e2e 는 `./image-fixtures` 로 부른다. + */ + +/** PNG 서명 + IHDR(가로·세로·비트 깊이·색 형식) — CRC 는 보지 않으므로 비워 둔다. `size` 바이트가 되게 0 으로 채운다 */ +export function pngBytes(width: number, height: number, size = 33): Buffer { + const data = Buffer.alloc(Math.max(size, 33)); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data); + data.writeUInt32BE(13, 8); + data.write('IHDR', 12, 'ascii'); + data.writeUInt32BE(width, 16); + data.writeUInt32BE(height, 20); + data[24] = 8; // 비트 깊이 + data[25] = 6; // 색 형식 RGBA + return data; +} + +/** GIF 머리 (논리 화면의 가로·세로, 리틀 엔디언) */ +export function gifBytes( + width: number, + height: number, + version = 'GIF89a', +): Buffer { + const data = Buffer.alloc(13); + data.write(version, 0, 'ascii'); + data.writeUInt16LE(width, 6); + data.writeUInt16LE(height, 8); + return data; +} + +/** SOI + APP0(JFIF) + (선택) 표지 앞 채움 바이트 + SOFn(세로·가로) + EOI */ +export function jpegBytes( + width: number, + height: number, + options: { sof?: number; fill?: boolean } = {}, +): Buffer { + const app0 = Buffer.from([ + 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + ]); + const sof = Buffer.from([ + 0xff, + options.sof ?? 0xc0, + 0x00, + 0x11, + 0x08, + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x22, + 0x00, + 0x02, + 0x11, + 0x01, + 0x03, + 0x11, + 0x01, + ]); + return Buffer.concat([ + Buffer.from([0xff, 0xd8]), + app0, + ...(options.fill ? [Buffer.from([0xff, 0xff])] : []), + sof, + Buffer.from([0xff, 0xd9]), + ]); +} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 655ab08..1b94ef6 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -19,6 +19,7 @@ const Map kErrorCodeMessages = { 'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', 'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', + 'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index f614c41..22b0eae 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -19,6 +19,7 @@ export const ERROR_CODE_MESSAGES = { MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', + MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES